feat: fix proxy compatibility and establish TUI foundation
This commit is contained in:
+62
-154
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user