516 lines
20 KiB
Go
516 lines
20 KiB
Go
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:]
|
||
}
|