package tui import ( "encoding/hex" "fmt" "image" "os" "path/filepath" "strconv" "strings" "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/r3g1tpr0cs/stegterm/internal/audio" "github.com/r3g1tpr0cs/stegterm/internal/stego" ) var stegoModes = []string{"bitplane", "channel", "grayscale", "invert", "xor", "lsb"} var stegoChannels = []stego.Channel{stego.ChannelRed, stego.ChannelGreen, stego.ChannelBlue, stego.ChannelAlpha} var audioWindowSizes = []int{512, 1024, 2048, 4096} var audioOps = []string{"spectrogram", "sstv"} var sstvModes = []string{"robot36"} // задел на будущее — пока только один режим // stegoKind различает, что сейчас загружено — картинка или WAV. От // этого зависит, что делает ctrl+g/ctrl+r и что показывает правая // панель метаданных. Один и тот же экран обслуживает оба сценария — // картинки и аудио/SSTV — не два разных режима запуска, а ветвление // по типу загруженного файла. type stegoKind int const ( stegoKindNone stegoKind = iota stegoKindImage stegoKindAudio ) // stegoModel — единственный экран stegterm. Само преобразование (и для // картинок, и для спектрограммы) считается синхронно — это дёшево, не // сетевой I/O, — но уходит в tea.Cmd ради единообразной дисциплины. // Рендер результата — самая ненадёжная часть всего проекта, см. // честную оговорку в stego.KittyEscape; для спектрограммы используется // тот же путь (stegoResultMsg → EncodePNG → KittyEscape), что и для // image-преобразований — с точки зрения рендера это просто ещё одна // картинка. // // View() делит экран на широкую левую колонку (поля ввода, статус) и // узкую правую панель метаданных — про причины см. комментарий там же. type stegoModel struct { pathInput textinput.Model secondPathInput textinput.Model // путь ко второму изображению — используется только в режиме "xor" (для аудио неприменимо) lsbLenInput textinput.Model // длина LSB-извлечения — используется только для картинок kind stegoKind // --- состояние для картинок --- mode int // индекс в stegoModes channel int // индекс в stegoChannels bit int // 0..7 original *image.RGBA // --- состояние для аудио --- wav *audio.WAV audioWindowIdx int // индекс в audioWindowSizes — используется только для операции "spectrogram" audioOp int // индекс в audioOps: 0 — spectrogram, 1 — sstv sstvModeIdx int // индекс в sstvModes — используется только для операции "sstv" // --- общее --- result *image.RGBA // и итог image-преобразования, и спектрограмма — один и тот же тип loadedPath string format string // для картинок — "png"/"jpeg"/"gif"; для аудио не используется fileSize int64 kittyEscape string // готовая escape-последовательность под текущий result, пересчитывается один раз при получении результата, а не на каждый View() extractedText string extractedHex string errMsg string statusMsg string width, height int focus int // 0 путь, 1 второй путь (только картинки), 2 длина LSB-извлечения (только картинки) } func newStegoView() stegoModel { pathIn := textinput.New() pathIn.Placeholder = "/путь/к/файлу.png или .wav" pathIn.CharLimit = 512 pathIn.Focus() secondIn := textinput.New() secondIn.Placeholder = "/путь/ко/второй/картинке.png (только для xor)" secondIn.CharLimit = 512 lsbLenIn := textinput.New() lsbLenIn.Placeholder = "256" lsbLenIn.CharLimit = 8 return stegoModel{ pathInput: pathIn, secondPathInput: secondIn, lsbLenInput: lsbLenIn, } } func (m *stegoModel) SetSize(width, height int) { m.width = width m.height = height } func (m stegoModel) Update(msg tea.Msg) (stegoModel, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "tab": m.focus = (m.focus + 1) % 3 m.pathInput.Blur() m.secondPathInput.Blur() m.lsbLenInput.Blur() switch m.focus { case 0: m.pathInput.Focus() case 1: m.secondPathInput.Focus() case 2: m.lsbLenInput.Focus() } return m, nil case "ctrl+o": return m, m.loadCmd() case "ctrl+g": if m.kind == stegoKindAudio { m.audioOp = (m.audioOp + 1) % len(audioOps) } else { m.mode = (m.mode + 1) % len(stegoModes) } return m, nil case "ctrl+n": switch { case m.kind == stegoKindAudio && audioOps[m.audioOp] == "spectrogram": m.audioWindowIdx = (m.audioWindowIdx + 1) % len(audioWindowSizes) case m.kind != stegoKindAudio: m.channel = (m.channel + 1) % len(stegoChannels) } return m, nil case "ctrl+b": switch { case m.kind == stegoKindAudio && audioOps[m.audioOp] == "sstv": m.sstvModeIdx = (m.sstvModeIdx + 1) % len(sstvModes) case m.kind != stegoKindAudio: m.bit = (m.bit + 1) % 8 } return m, nil case "ctrl+r": if m.kind == stegoKindAudio { if audioOps[m.audioOp] == "sstv" { return m, m.applySSTVCmd() } return m, m.applyAudioCmd() } return m, m.applyImageCmd() case "ctrl+s": return m, m.saveCmd() case "ctrl+v": return m, kittyPrintCmd(m.kittyEscape) } } var cmd tea.Cmd switch m.focus { case 0: m.pathInput, cmd = m.pathInput.Update(msg) case 1: m.secondPathInput, cmd = m.secondPathInput.Update(msg) case 2: m.lsbLenInput, cmd = m.lsbLenInput.Update(msg) } return m, cmd } // loadCmd (ctrl+o) различает картинку и WAV по расширению файла — // просто и предсказуемо для CTF-контекста, где расширения обычно // осмысленные. Для .wav — audio.LoadWAV, для всего остального — попытка // декодировать как изображение (с честной ошибкой, если не вышло ни то, // ни другое). func (m stegoModel) loadCmd() tea.Cmd { path := strings.TrimSpace(m.pathInput.Value()) return func() tea.Msg { if path == "" { return stegoErrMsg{fmt.Errorf("укажи путь к файлу")} } var size int64 if fi, err := os.Stat(path); err == nil { size = fi.Size() } if strings.EqualFold(filepath.Ext(path), ".wav") { w, err := audio.LoadWAV(path) if err != nil { return stegoErrMsg{err} } return audioLoadedMsg{wav: w, path: path, size: size} } img, format, err := stego.Load(path) if err != nil { return stegoErrMsg{fmt.Errorf("не удалось загрузить как изображение (для аудио нужно расширение .wav): %w", err)} } return stegoLoadedMsg{img: img, path: path, format: format, size: size} } } // applyImageCmd (ctrl+r в режиме картинки) применяет текущий выбранный // режим к загруженному изображению. Для xor заодно грузит второе // изображение с диска — так не нужна отдельная команда "загрузить // второе" до применения. func (m stegoModel) applyImageCmd() tea.Cmd { original := m.original mode := stegoModes[m.mode] channel := stegoChannels[m.channel] bit := m.bit secondPath := strings.TrimSpace(m.secondPathInput.Value()) lsbLenStr := m.lsbLenInput.Value() return func() tea.Msg { if original == nil { return stegoErrMsg{fmt.Errorf("сначала загрузи изображение (ctrl+o)")} } switch mode { case "bitplane": return stegoResultMsg{result: stego.BitPlane(original, channel, bit)} case "channel": return stegoResultMsg{result: stego.ChannelIsolate(original, channel)} case "grayscale": return stegoResultMsg{result: stego.Grayscale(original)} case "invert": return stegoResultMsg{result: stego.Invert(original)} case "xor": if secondPath == "" { return stegoErrMsg{fmt.Errorf("для xor укажи путь ко второму изображению")} } second, _, err := stego.Load(secondPath) if err != nil { return stegoErrMsg{err} } result, err := stego.XOR(original, second) if err != nil { return stegoErrMsg{err} } return stegoResultMsg{result: result} case "lsb": n, _ := strconv.Atoi(lsbLenStr) if n <= 0 { n = 256 } data := stego.ExtractLSB(original, channel, bit, n) return stegoExtractedMsg{data: data} default: return stegoErrMsg{fmt.Errorf("неизвестный режим %q", mode)} } } } // applyAudioCmd (ctrl+r в режиме аудио) считает спектрограмму текущим // размером окна и оборачивает её в тот же stegoResultMsg, что и // image-преобразования — с точки зрения рендера (EncodePNG/KittyEscape) // разницы никакой, это просто *image.RGBA. func (m stegoModel) applyAudioCmd() tea.Cmd { wav := m.wav windowSize := audioWindowSizes[m.audioWindowIdx] return func() tea.Msg { if wav == nil { return stegoErrMsg{fmt.Errorf("сначала загрузи .wav-файл (ctrl+o)")} } hop := windowSize / 4 // 75% перекрытие — стандартный компромисс между временным и частотным разрешением frames := audio.Spectrogram(wav.Samples, windowSize, hop) if len(frames) == 0 { return stegoErrMsg{fmt.Errorf("файл слишком короткий для окна в %d сэмплов — попробуй окно поменьше (ctrl+n)", windowSize)} } img := audio.RenderSpectrogram(frames) return stegoResultMsg{result: img} } } // applySSTVCmd (ctrl+r в режиме аудио, операция "sstv") декодирует // Robot36 через audio.DecodeRobot36 — та же самая честная оговорка про // риск калибровки, что и в doc-комментарии sstv.go, здесь не повторяю. // Результат — снова *image.RGBA в общем stegoResultMsg, тот же путь // рендера, что и у спектрограммы и у image-преобразований. func (m stegoModel) applySSTVCmd() tea.Cmd { wav := m.wav return func() tea.Msg { if wav == nil { return stegoErrMsg{fmt.Errorf("сначала загрузи .wav-файл (ctrl+o)")} } img, err := audio.DecodeRobot36(wav) if err != nil { return stegoErrMsg{fmt.Errorf("декодирование SSTV: %w", err)} } return stegoResultMsg{result: img} } } // saveCmd (ctrl+s) сохраняет текущий result (изображение или // спектрограмма — не различает) в PNG-файл под // ~/.stegterm/stego-exports/. func (m stegoModel) saveCmd() tea.Cmd { result := m.result return func() tea.Msg { if result == nil { return stegoErrMsg{fmt.Errorf("нечего сохранять — сначала примени преобразование (ctrl+r)")} } path, err := defaultStegoExportPath() if err != nil { return stegoErrMsg{err} } if err := stego.SaveFile(result, path); err != nil { return stegoErrMsg{err} } return stegoSavedMsg{path: path} } } func defaultStegoExportPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err) } dir := filepath.Join(home, ".stegterm", "stego-exports") if err := os.MkdirAll(dir, 0o700); err != nil { return "", fmt.Errorf("не удалось создать %s: %w", dir, err) } return filepath.Join(dir, fmt.Sprintf("stego-%d.png", time.Now().Unix())), nil } // kittyPrintCmd пишет готовую Kitty-escape-последовательность напрямую // в stdout, в обход обычного View()-рендера bubbletea — см. развёрнутую // оговорку в stego.KittyEscape про то, почему это прагматичный побочный // эффект, а не гарантированно работающий встроенный механизм. Ничего не // делает, если escape пуст (результата ещё нет или ошибка кодирования). func kittyPrintCmd(escape string) tea.Cmd { return func() tea.Msg { if escape != "" { fmt.Print(escape) } return nil } } // formatExtracted представляет извлечённые LSB-байты и как текст // (непечатные символы заменены на '.', чтобы не портить вывод терминала), // и как hex — иногда искомое читается только в одном из двух видов. func formatExtracted(data []byte) (text, hexStr string) { var tb strings.Builder for _, b := range data { if b >= 0x20 && b < 0x7f { tb.WriteByte(b) } else { tb.WriteByte('.') } } return tb.String(), hex.EncodeToString(data) } // View делит экран на широкую левую колонку (3/4 ширины) и узкую // панель метаданных справа (1/4, через panel() из style.go). На узком // терминале (меньше ~70 колонок) колонки складываются друг под друга. // // Известный компромисс: поля "второе изображение" и "длина LSB" всегда // на экране, даже в режиме аудио, где они не используются — условный // layout ради их скрытия усложнил бы View() заметнее, чем стоит того; // в audio-режиме они просто ничего не делают, tab всё ещё до них // доходит, но эффекта от печати в них нет. func (m stegoModel) View() string { focusMark := func(i int) string { if m.focus == i { return "▸ " } return " " } var left strings.Builder left.WriteString(focusMark(0) + "Файл (картинка или .wav)\n") left.WriteString(m.pathInput.View()) left.WriteString("\n\n") left.WriteString(focusMark(1) + "Второе изображение (для xor)\n") left.WriteString(m.secondPathInput.View()) left.WriteString("\n\n") if m.kind == stegoKindAudio { if audioOps[m.audioOp] == "sstv" { left.WriteString(fmt.Sprintf("Аудио: SSTV (ctrl+g) Режим: %s (ctrl+b)\n\n", sstvModes[m.sstvModeIdx])) } else { left.WriteString(fmt.Sprintf("Аудио: спектрограмма (ctrl+g) Окно FFT: %d (ctrl+n)\n\n", audioWindowSizes[m.audioWindowIdx])) } } else { left.WriteString(fmt.Sprintf("Режим: %-9s (ctrl+g) Канал: %s (ctrl+n) Бит: %d (ctrl+b)\n\n", stegoModes[m.mode], stegoChannels[m.channel], m.bit)) } left.WriteString(focusMark(2) + "Длина LSB-извлечения, байт (только для картинок)\n") left.WriteString(m.lsbLenInput.View()) left.WriteString("\n\n") if m.errMsg != "" { left.WriteString("Ошибка: " + m.errMsg + "\n") } if m.statusMsg != "" { left.WriteString(m.statusMsg + "\n") } if m.extractedText != "" || m.extractedHex != "" { left.WriteString("\nИзвлечённые байты (LSB):\n") left.WriteString(" текст: " + m.extractedText + "\n") left.WriteString(" hex: " + m.extractedHex + "\n") } left.WriteString("\ntab — между полями · ctrl+o — загрузить · ctrl+r — применить · ctrl+v — показать заново\n") left.WriteString("ctrl+s — экспорт PNG · ctrl+c — выход\n") var meta strings.Builder switch m.kind { case stegoKindAudio: if m.wav != nil { fmt.Fprintf(&meta, "Путь: %s\n", truncateMiddle(m.loadedPath, 28)) if m.fileSize > 0 { fmt.Fprintf(&meta, "Файл: %s\n", humanBytes(uint64(m.fileSize))) } fmt.Fprintf(&meta, "Частота: %d Гц\n", m.wav.SampleRate) if m.wav.SampleRate > 0 { dur := time.Duration(float64(len(m.wav.Samples)) / float64(m.wav.SampleRate) * float64(time.Second)) fmt.Fprintf(&meta, "Длит-ть: %s\n", dur.Round(10*time.Millisecond)) } fmt.Fprintf(&meta, "Сэмплов: %d\n", len(m.wav.Samples)) } else { meta.WriteString("аудио не загружено\n") } fmt.Fprintf(&meta, "\nОперация: %s\n", audioOps[m.audioOp]) if audioOps[m.audioOp] == "sstv" { fmt.Fprintf(&meta, "SSTV-режим: %s\n", sstvModes[m.sstvModeIdx]) } else { fmt.Fprintf(&meta, "Окно FFT: %d\n", audioWindowSizes[m.audioWindowIdx]) } if m.result != nil { bnd := m.result.Bounds() fmt.Fprintf(&meta, "\nРезультат:\n%dx%d\n", bnd.Dx(), bnd.Dy()) } case stegoKindImage: if m.original != nil { bnd := m.original.Bounds() fmt.Fprintf(&meta, "Путь: %s\n", truncateMiddle(m.loadedPath, 28)) fmt.Fprintf(&meta, "Формат: %s\n", strings.ToUpper(m.format)) if m.fileSize > 0 { fmt.Fprintf(&meta, "Файл: %s\n", humanBytes(uint64(m.fileSize))) } fmt.Fprintf(&meta, "Размер: %dx%d\n", bnd.Dx(), bnd.Dy()) } else { meta.WriteString("не загружено\n") } fmt.Fprintf(&meta, "\nРежим: %s\n", stegoModes[m.mode]) fmt.Fprintf(&meta, "Канал: %s\n", stegoChannels[m.channel]) fmt.Fprintf(&meta, "Бит: %d\n", m.bit) if m.result != nil { bnd := m.result.Bounds() fmt.Fprintf(&meta, "\nРезультат:\n%dx%d\n", bnd.Dx(), bnd.Dy()) } if m.extractedHex != "" { fmt.Fprintf(&meta, "\nИзвлечено:\n%d байт\n", len(m.extractedHex)/2) } default: meta.WriteString("файл не загружен\n") } totalWidth := m.width if totalWidth <= 0 { totalWidth = 80 } if totalWidth < 70 { return left.String() + "\n" + panel("метаданные", meta.String(), totalWidth-6) } rightWidth := totalWidth / 4 if rightWidth < 18 { rightWidth = 18 } leftWidth := totalWidth - rightWidth - 6 if leftWidth < 20 { leftWidth = 20 } leftStyled := lipgloss.NewStyle().Width(leftWidth).Render(left.String()) rightStyled := panel("метаданные", meta.String(), rightWidth) return lipgloss.JoinHorizontal(lipgloss.Top, leftStyled, rightStyled) } // truncateMiddle обрезает середину длинного пути ("...", а не конец) — // хвост файла (имя) обычно важнее начала (общие для всех файлов // директории) при взгляде на узкую колонку. func truncateMiddle(s string, max int) string { if len(s) <= max { return s } half := (max - 3) / 2 return s[:half] + "..." + s[len(s)-half:] }