404 lines
12 KiB
Go
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
|
|
}
|