feat: fix proxy compatibility and establish TUI foundation

This commit is contained in:
proxyctl coordinator
2026-07-17 20:30:52 +08:00
parent 0f4c122eff
commit daeb788eac
34 changed files with 1521 additions and 176 deletions
+62 -154
View File
@@ -3,7 +3,6 @@ package tui
import (
"context"
"errors"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -33,8 +32,11 @@ type formState struct {
type Model struct {
application app.Module
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
form formState
confirm bool
@@ -84,8 +86,19 @@ func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
}
}
func NewModel(application app.Module) Model {
return Model{application: application, snapshot: application.Snapshot(), detect: probe.Detect}
func NewModel(application app.Module, configure ...ModelOption) Model {
options := defaultModelOptions()
for _, option := range configure {
option(&options)
}
return Model{
application: application,
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
snapshot: application.Snapshot(),
width: 80,
height: 24,
detect: probe.Detect,
}
}
func (m Model) Init() tea.Cmd { return nil }
@@ -102,6 +115,12 @@ func (m Model) operationInFlight() bool {
}
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := message.(tea.WindowSizeMsg); ok {
m.width = size.Width
m.height = size.Height
return m, nil
}
// A deferred tea.Quit is queued only after the in-flight completion has
// been consumed. Until Bubble Tea processes that QuitMsg, keep the model
// terminal so intervening input cannot cancel a confirmation or start a
@@ -120,7 +139,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
m.refreshing = false
m.snapshot = completed.result.Snapshot
if completed.result.Err != nil {
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
m.status = "Error: " + completed.result.Err.Error()
} else {
m.status = completed.result.Notice
}
@@ -156,7 +175,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, m.deferredQuit()
}
if progress.result.Err != nil {
m.status = "\x1b[31mError: " + progress.result.Err.Error() + "\x1b[0m"
m.status = "Error: " + progress.result.Err.Error()
} else if progress.result.Notice != "" {
m.status = progress.result.Notice
}
@@ -184,7 +203,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
m.status = completed.result.Confirmation
} else if completed.result.Err != nil {
m.targetConfirm = nil
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
m.status = "Error: " + completed.result.Err.Error()
} else {
m.targetConfirm = nil
m.status = completed.result.Notice
@@ -202,7 +221,26 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
case !done.caps.HTTP && !done.caps.SOCKS5:
m.status = "Protocol Probe: unrecognized protocol at " + done.address
default:
rewritten, err := probe.URL(m.form.profile.SingleURL, done.caps)
raw := m.form.profile.SingleURL
if done.caps.HTTP && done.caps.SOCKS5 {
httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true})
if err != nil {
m.status = "Protocol Probe: " + err.Error()
return m, nil
}
socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true})
if err != nil {
m.status = "Protocol Probe: " + err.Error()
return m, nil
}
m.form.profile.Mode = profile.AdvancedMode
m.form.profile.HTTPURL = httpURL
m.form.profile.HTTPSURL = httpURL
m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
m.status = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + done.address
return m, nil
}
rewritten, err := probe.URL(raw, done.caps)
if err != nil {
m.status = "Protocol Probe: " + err.Error()
return m, nil
@@ -514,10 +552,7 @@ func probeAddress(raw string) (string, error) {
}
func (m Model) selectedProfile() (profile.Profile, bool) {
if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) {
return profile.Profile{}, false
}
return m.snapshot.Profiles[m.selected], true
return profileAt(m.snapshot, m.selected)
}
func (m *Model) selectName(name string) {
@@ -530,152 +565,25 @@ func (m *Model) selectName(name string) {
}
func (m Model) View() string {
switch m.page {
case profilesPage:
return m.profilesView()
case formPage:
return m.formView()
case helpPage:
return m.helpView()
default:
return m.mainView()
}
return m.presentation.Render(presentationState{
snapshot: m.snapshot,
page: m.page,
width: m.width,
height: m.height,
selected: m.selected,
form: m.form,
confirm: m.confirm,
targetConfirm: m.targetConfirm != nil,
profileConfirm: m.profileConfirm != nil,
status: m.status,
applying: m.applying,
profileTransition: m.profileTransition,
progressTarget: m.progressTarget,
})
}
func (m Model) availableTargets() []app.TargetSnapshot {
var result []app.TargetSnapshot
for _, item := range m.snapshot.Targets {
if item.Available {
result = append(result, item)
}
}
return result
}
func (m Model) mainView() string {
var b strings.Builder
b.WriteString("proxyctl — Target panel\n\n")
if m.snapshot.ActiveProfile == "" {
b.WriteString("No Active Profile\n")
b.WriteString("Create and activate a Proxy Profile before enabling Targets.\n")
} else {
fmt.Fprintf(&b, "Active Profile: %s\n", m.snapshot.ActiveProfile)
}
targets := m.availableTargets()
if len(targets) == 0 {
b.WriteString("\nNo Targets are currently Available. Press ? to review detection reasons.\n")
} else {
b.WriteString("\nTargets\n")
for i, item := range targets {
cursor := " "
if i == m.selected {
cursor = "> "
}
state := string(item.State)
if (m.progressTarget != "" && item.ID == m.progressTarget) || (!m.profileTransition && m.progressTarget == "" && m.applying && i == m.selected) {
state = "Applying…"
}
fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name)
}
if m.selected >= 0 && m.selected < len(targets) {
selected := targets[m.selected]
fmt.Fprintf(&b, "\n%s\n%s\n%s\n", selected.Description.Summary, selected.Description.Details, selected.Evidence)
}
}
if m.targetConfirm != nil && !m.applying {
b.WriteString("\ny: confirm n/Esc: cancel\n")
} else {
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles r: refresh ?: help q/Esc: quit\n")
}
if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status)
}
return b.String()
}
func (m Model) helpView() string {
var b strings.Builder
b.WriteString("proxyctl Help — built-in Targets\n\n")
for _, item := range m.snapshot.Targets {
fmt.Fprintf(&b, "%s\n%s\n%s\n", item.Description.Name, item.Description.Summary, item.Description.Details)
if item.Available {
fmt.Fprintf(&b, "Detected: %s\n\n", item.AvailabilityEvidence)
} else {
fmt.Fprintf(&b, "Not detected: %s\n\n", item.AvailabilityEvidence)
}
}
b.WriteString("Keys: ↑/k and ↓/j navigate; Space toggles; p manages Proxy Profiles; r refreshes; ? opens help; q/Escape returns or exits.\n")
return b.String()
}
func (m Model) profilesView() string {
var b strings.Builder
b.WriteString("Proxy Profiles\n\n")
if len(m.snapshot.Profiles) == 0 {
b.WriteString("No Proxy Profiles. Press n to create one.\n")
}
for i, item := range m.snapshot.Profiles {
cursor, active := " ", " "
if i == m.selected {
cursor = "> "
}
if item.Name == m.snapshot.ActiveProfile {
active = "*"
}
fmt.Fprintf(&b, "%s%s %s\n", cursor, active, item.Name)
}
if selected, ok := m.selectedProfile(); ok {
b.WriteString("\nSelected Profile\n")
fmt.Fprintf(&b, "Mode: %s\n", selected.Mode)
if selected.Mode == profile.SingleMode {
fmt.Fprintf(&b, "Proxy: %s\n", profile.ObscureURL(selected.SingleURL))
} else {
fmt.Fprintf(&b, "HTTP: %s\nHTTPS: %s\nALL/SOCKS: %s\n",
profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL))
}
fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", "))
}
if m.profileConfirm != nil {
b.WriteString("\ny: confirm n/Esc: cancel\n")
} else if m.confirm {
if len(m.snapshot.Profiles) == 1 {
b.WriteString("\nDelete Profile — this is the final Proxy Profile. Managed configuration will remain in place and become Drifted. y: confirm n/Esc: cancel\n")
} else {
b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n")
}
} else {
b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n")
}
if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status)
}
return b.String()
}
func (m Model) formView() string {
var b strings.Builder
action := "Create"
if m.form.originalName != "" {
action = "Edit"
}
fmt.Fprintf(&b, "%s Proxy Profile — %s mode\n\n", action, m.form.profile.Mode)
labels := m.form.labels()
for i, label := range labels {
cursor := " "
if i == m.form.field {
cursor = "> "
}
value := m.form.valueAt(i)
if label != "Name" && label != "Bypass" {
value = profile.ObscureURL(value)
}
fmt.Fprintf(&b, "%s%s: %s\n", cursor, label, value)
}
b.WriteString("\nTab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Ctrl+D: detect protocol Esc: cancel\n")
if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status)
}
return b.String()
return availableTargets(m.snapshot)
}
func (f formState) labels() []string {