stegterm/internal/tui/stegoview.go
2026-09-14 11:00:27 +03:00

516 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:]
}