Files
proxy/internal/tui/profile_form.go
T

404 lines
12 KiB
Go

package tui
import (
"errors"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/probe"
"proxyctl/internal/profile"
)
type formInput struct {
label string
field profile.Field
input textinput.Model
}
type formDefinition struct {
label string
field profile.Field
value string
}
type probeToken struct {
formID uint64
generation uint64
source profile.Field
}
type protocolProbeFieldState struct {
active bool
token probeToken
address string
feedback string
sourceValue string
sourceAttached bool
}
type formState struct {
id uint64
originalName string
profile profile.Profile
inputs []formInput
field int
bypassPreviewLine int
validation profile.Validation
advancedInitialized bool
probeGeneration uint64
probe protocolProbeFieldState
}
func newFormState(id uint64, originalName string, value profile.Profile) formState {
if value.Mode == "" {
value.Mode = profile.SingleMode
}
form := formState{
id: id,
originalName: originalName,
profile: value,
advancedInitialized: value.Mode == profile.AdvancedMode,
}
form.rebuildInputs(0)
return form
}
func (f *formState) rebuildInputs(focus int) tea.Cmd {
f.bypassPreviewLine = 0
definitions := []formDefinition{{"Name", profile.NameField, f.profile.Name}}
if f.profile.Mode == profile.AdvancedMode {
definitions = append(definitions,
formDefinition{"HTTP URL", profile.HTTPURLField, f.profile.HTTPURL},
formDefinition{"HTTPS URL", profile.HTTPSURLField, f.profile.HTTPSURL},
formDefinition{"ALL/SOCKS URL", profile.AllURLField, f.profile.AllURL},
)
} else {
definitions = append(definitions, formDefinition{"Single proxy URL", profile.SingleURLField, f.profile.SingleURL})
}
definitions = append(definitions, formDefinition{"Bypass", profile.BypassField, strings.Join(f.profile.Bypass, ", ")})
f.inputs = make([]formInput, len(definitions))
for i, definition := range definitions {
input := textinput.New()
input.Prompt = ""
input.Placeholder = fieldPlaceholder(definition.field)
input.CharLimit = 4096
input.Width = 48
input.SetValue(sanitizeUntrustedControls(definition.value))
input.CursorEnd()
f.inputs[i] = formInput{label: definition.label, field: definition.field, input: input}
}
f.field = min(max(0, focus), len(f.inputs)-1)
command := f.inputs[f.field].input.Focus()
f.refreshValidation()
return command
}
func fieldPlaceholder(field profile.Field) string {
switch field {
case profile.NameField:
return "required"
case profile.BypassField:
return "comma-separated; optional"
default:
return "host:port or protocol://host:port"
}
}
func (f *formState) move(delta int) tea.Cmd {
if len(f.inputs) == 0 {
return nil
}
f.inputs[f.field].input.Blur()
f.field = (f.field + delta + len(f.inputs)) % len(f.inputs)
f.bypassPreviewLine = 0
return f.inputs[f.field].input.Focus()
}
func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd {
if len(f.inputs) == 0 {
return nil
}
if key.Type == tea.KeyRunes {
key.Runes = []rune(sanitizeUntrustedControls(string(key.Runes)))
}
// Bubbles textinput intentionally collapses pasted newlines. Bypass uses
// canonical comma/newline delimiters, so preserve that boundary as commas.
if f.inputs[f.field].field == profile.BypassField && key.Type == tea.KeyRunes {
key.Runes = []rune(strings.NewReplacer("\r\n", ",", "\n", ",", "\r", ",").Replace(string(key.Runes)))
}
previous := f.inputs[f.field].input.Value()
var commands []tea.Cmd
if key.Type == tea.KeyRunes && len(key.Runes) > 1 {
// A pasted word such as "home" must remain text rather than matching a
// named navigation binding in textinput's string-based key map.
for _, pasted := range key.Runes {
updated, command := f.inputs[f.field].input.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{pasted}})
f.inputs[f.field].input = updated
commands = append(commands, command)
}
} else {
updated, command := f.inputs[f.field].input.Update(key)
f.inputs[f.field].input = updated
commands = append(commands, command)
}
if f.inputs[f.field].input.Value() != previous {
f.bypassPreviewLine = 0
changedField := f.inputs[f.field].field
f.syncFocusedValue()
if isProxyURLField(changedField) {
f.invalidateProbe()
}
f.refreshValidation()
}
return tea.Batch(commands...)
}
func (f *formState) syncFocusedValue() {
if len(f.inputs) == 0 {
return
}
value := f.inputs[f.field].input.Value()
switch f.inputs[f.field].field {
case profile.NameField:
f.profile.Name = value
case profile.SingleURLField:
f.profile.SingleURL = value
case profile.HTTPURLField:
f.profile.HTTPURL = value
case profile.HTTPSURLField:
f.profile.HTTPSURL = value
case profile.AllURLField:
f.profile.AllURL = value
case profile.BypassField:
f.profile.Bypass = []string{value}
}
}
func (f *formState) refreshValidation() {
f.validation = profile.ValidateFields(f.profile)
}
func (f *formState) toggleMode() tea.Cmd {
f.syncFocusedValue()
f.invalidateProbe()
if f.profile.Mode == profile.SingleMode {
f.profile.Mode = profile.AdvancedMode
if !f.advancedInitialized {
f.profile.HTTPURL = f.profile.SingleURL
f.profile.HTTPSURL = f.profile.SingleURL
f.profile.AllURL = f.profile.SingleURL
f.advancedInitialized = true
}
} else {
f.profile.Mode = profile.SingleMode
}
return f.rebuildInputs(0)
}
func (f formState) fieldCount() int { return len(f.inputs) }
func (f formState) currentField() profile.Field {
if f.field < 0 || f.field >= len(f.inputs) {
return ""
}
return f.inputs[f.field].field
}
func isProxyURLField(field profile.Field) bool {
switch field {
case profile.SingleURLField, profile.HTTPURLField, profile.HTTPSURLField, profile.AllURLField:
return true
default:
return false
}
}
func (f *formState) invalidateProbe() {
f.probeGeneration++
f.probe = protocolProbeFieldState{}
}
// startProbe owns applicability, duplicate suppression, source capture, and
// token issuance so callers cannot accidentally start work outside formState's
// stale-result rules.
func (f *formState) startProbe() (string, probeToken, bool) {
if f.profile.Mode != profile.SingleMode || f.currentField() != profile.SingleURLField || f.probe.active {
return "", probeToken{}, false
}
f.syncFocusedValue()
f.invalidateProbe()
token := probeToken{formID: f.id, generation: f.probeGeneration, source: profile.SingleURLField}
f.probe.token = token
f.probe.sourceValue = f.profile.SingleURL
address, err := probeAddress(f.probe.sourceValue)
if err != nil {
f.probe.feedback = "Protocol Probe: " + err.Error()
return "", probeToken{}, false
}
f.probe.active = true
f.probe.address = address
f.probe.feedback = "Protocol Probe: checking " + address + "…"
return address, token, true
}
// applyProbeResult validates and applies a completion as one form-owned
// operation. Every stale-result invariant stays here: form identity,
// generation, source field, active work, mode, and unchanged source value.
func (f *formState) applyProbeResult(token probeToken, caps probe.Capabilities, resultErr error) tea.Cmd {
if !f.probe.active || token != f.probe.token || token.formID != f.id ||
token.source != profile.SingleURLField || f.profile.Mode != profile.SingleMode ||
f.profile.SingleURL != f.probe.sourceValue {
return nil
}
f.probe.active = false
address, raw := f.probe.address, f.probe.sourceValue
switch {
case resultErr != nil:
f.probe.feedback = "Protocol Probe failed for " + address
case !caps.HTTP && !caps.SOCKS5:
f.probe.feedback = "Protocol Probe: unrecognized protocol at " + address
case caps.HTTP && caps.SOCKS5:
httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true})
if err != nil {
f.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return nil
}
socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true})
if err != nil {
f.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return nil
}
f.profile.Mode = profile.AdvancedMode
f.profile.HTTPURL = httpURL
f.profile.HTTPSURL = httpURL
f.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
f.advancedInitialized = true
f.probe.feedback = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + address
f.probe.sourceAttached = true
return f.rebuildInputs(1)
default:
rewritten, err := probe.URL(raw, caps)
if err != nil {
f.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return nil
}
f.profile.SingleURL = rewritten
f.setInputValue(profile.SingleURLField, rewritten)
kind := "SOCKS5"
if caps.HTTP {
kind = "HTTP"
}
f.probe.feedback = "Protocol Probe: detected " + kind + " proxy at " + address
}
f.refreshValidation()
return nil
}
func (f *formState) setInputValue(field profile.Field, value string) {
for i := range f.inputs {
if f.inputs[i].field != field {
continue
}
position := f.inputs[i].input.Position()
f.inputs[i].input.SetValue(value)
f.inputs[i].input.SetCursor(position)
return
}
}
func (f formState) probeText(field profile.Field) string {
if f.profile.Mode == profile.SingleMode && field == profile.SingleURLField {
if f.probe.feedback != "" {
return f.probe.feedback
}
return "Protocol Probe [Ctrl+D]: available"
}
return ""
}
func (f formState) hasProbeSourceAttachment() bool {
return f.profile.Mode == profile.AdvancedMode && f.probe.sourceAttached &&
f.probe.token.source == profile.SingleURLField && f.probe.feedback != ""
}
func probeAddress(raw string) (string, error) {
u, err := profile.ParseProxyURL(raw)
if err != nil {
return "", errors.New("Single proxy URL must include host:port")
}
return u.Host, nil
}
func (f *formState) scrollBypassPreview(delta int) {
if f.currentField() != profile.BypassField {
return
}
// Line zero is the preview heading; each following line represents one
// normalized or invalid validation item. Anchoring the viewport to that
// item keeps Page navigation stable across terminal resizes.
maximum := len(f.bypassAnalysis())
f.bypassPreviewLine = min(maximum, max(0, f.bypassPreviewLine+delta))
}
func (f formState) renderedInput(index, width int) string {
if index < 0 || index >= len(f.inputs) {
return ""
}
input := f.inputs[index].input
input.Width = max(12, width-len(f.inputs[index].label)-18)
position := input.Position()
// Mask before horizontal clipping for every field. This prevents a
// malformed secret-bearing URL pasted into Name or Bypass from becoming
// unrecognizable to the final frame redactor when its prefix scrolls away.
input.SetValue(profile.ObscureURLForEditing(input.Value()))
input.SetCursor(position)
return input.View()
}
func (f formState) nameConflict(profiles []profile.Profile) string {
name := strings.TrimSpace(f.profile.Name)
if name == "" {
return ""
}
for _, candidate := range profiles {
if candidate.Name == name && candidate.Name != f.originalName {
return fmt.Sprintf("a Profile named %q already exists", name)
}
}
return ""
}
func (f formState) validationText(index int) string {
if index < 0 || index >= len(f.inputs) {
return ""
}
field := f.inputs[index].field
if field == profile.BypassField {
return ""
}
if err := f.validation.FieldError(field); err != nil {
return err.Error()
}
return ""
}
func (f formState) bypassAnalysis() []string {
var lines []string
seen := make(map[string]bool)
for _, item := range f.validation.BypassItems {
if item.Err != nil {
lines = append(lines, fmt.Sprintf("! item %d %q — invalid: %s", item.Index, item.Input, item.Err))
continue
}
if !seen[item.Normalized] {
seen[item.Normalized] = true
lines = append(lines, "• "+item.Normalized)
}
}
return lines
}