feat: add persistent proxy profile management

This commit is contained in:
proxyctl coordinator
2026-07-17 02:08:51 +08:00
parent 73b027b85b
commit f9469cf6a2
13 changed files with 1390 additions and 0 deletions
+347
View File
@@ -0,0 +1,347 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
type page int
const (
mainPage page = iota
profilesPage
formPage
)
type formState struct {
originalName string
profile profile.Profile
field int
}
type Model struct {
application app.Module
snapshot app.Snapshot
page page
selected int
form formState
confirm bool
status string
}
func NewModel(application app.Module) Model {
return Model{application: application, snapshot: application.Snapshot()}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
key, ok := message.(tea.KeyMsg)
if !ok {
return m, nil
}
switch m.page {
case mainPage:
return m.updateMain(key)
case profilesPage:
return m.updateProfiles(key)
case formPage:
return m.updateForm(key)
default:
return m, nil
}
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "p":
m.page = profilesPage
m.status = ""
case "q", "esc", "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.confirm {
switch key.String() {
case "y":
if selected, ok := m.selectedProfile(); ok {
m.apply(app.Intent{Kind: app.DeleteProfile, Name: selected.Name, Confirm: true})
}
m.confirm = false
case "n", "esc", "q":
m.confirm = false
}
return m, nil
}
switch key.String() {
case "q", "esc":
m.page = mainPage
m.status = ""
case "up", "k":
if m.selected > 0 {
m.selected--
}
case "down", "j":
if m.selected+1 < len(m.snapshot.Profiles) {
m.selected++
}
case "n":
m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}}
m.page = formPage
m.status = ""
case "e":
if selected, ok := m.selectedProfile(); ok {
m.form = formState{originalName: selected.Name, profile: selected}
m.page = formPage
m.status = ""
}
case "a", "enter":
if selected, ok := m.selectedProfile(); ok {
m.apply(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
}
case "d":
if _, ok := m.selectedProfile(); ok {
m.confirm = true
}
}
return m, nil
}
func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "esc":
m.page = profilesPage
m.status = ""
return m, nil
case "ctrl+s":
kind := app.CreateProfile
if m.form.originalName != "" {
kind = app.UpdateProfile
}
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile}
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
if result.Err != nil {
m.status = "Error: " + result.Err.Error()
return m, nil
}
m.status = result.Notice
m.page = profilesPage
m.selectName(intent.Profile.Name)
return m, nil
case "ctrl+t":
if m.form.profile.Mode == profile.SingleMode {
m.form.profile.Mode = profile.AdvancedMode
if m.form.profile.HTTPURL == "" && m.form.profile.HTTPSURL == "" && m.form.profile.AllURL == "" {
m.form.profile.HTTPURL = m.form.profile.SingleURL
m.form.profile.HTTPSURL = m.form.profile.SingleURL
m.form.profile.AllURL = m.form.profile.SingleURL
}
} else {
m.form.profile.Mode = profile.SingleMode
}
m.form.field = 0
return m, nil
case "tab", "down":
m.form.field = (m.form.field + 1) % m.form.fieldCount()
return m, nil
case "shift+tab", "up":
m.form.field = (m.form.field - 1 + m.form.fieldCount()) % m.form.fieldCount()
return m, nil
case "backspace":
value := []rune(m.form.value())
if len(value) > 0 {
m.form.setValue(string(value[:len(value)-1]))
}
return m, nil
}
if key.Type == tea.KeyRunes {
m.form.setValue(m.form.value() + string(key.Runes))
}
return m, nil
}
func (m *Model) apply(intent app.Intent) {
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 {
m.selected--
}
if result.Err != nil {
m.status = "Error: " + result.Err.Error()
} else {
m.status = result.Notice
}
}
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
}
func (m *Model) selectName(name string) {
for i := range m.snapshot.Profiles {
if m.snapshot.Profiles[i].Name == name {
m.selected = i
return
}
}
}
func (m Model) View() string {
switch m.page {
case profilesPage:
return m.profilesView()
case formPage:
return m.formView()
default:
return m.mainView()
}
}
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)
}
b.WriteString("\nNo Targets are available in this build.\n")
b.WriteString("\np: manage Proxy Profiles q/Esc: quit\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.confirm {
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 Esc: cancel\n")
if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status)
}
return b.String()
}
func (f formState) labels() []string {
if f.profile.Mode == profile.AdvancedMode {
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}
}
return []string{"Name", "Single proxy URL", "Bypass"}
}
func (f formState) fieldCount() int { return len(f.labels()) }
func (f formState) value() string { return f.valueAt(f.field) }
func (f formState) valueAt(index int) string {
if index == 0 {
return f.profile.Name
}
if f.profile.Mode == profile.AdvancedMode {
switch index {
case 1:
return f.profile.HTTPURL
case 2:
return f.profile.HTTPSURL
case 3:
return f.profile.AllURL
default:
return strings.Join(f.profile.Bypass, ", ")
}
}
if index == 1 {
return f.profile.SingleURL
}
return strings.Join(f.profile.Bypass, ", ")
}
func (f *formState) setValue(value string) {
if f.field == 0 {
f.profile.Name = value
return
}
if f.profile.Mode == profile.AdvancedMode {
switch f.field {
case 1:
f.profile.HTTPURL = value
case 2:
f.profile.HTTPSURL = value
case 3:
f.profile.AllURL = value
default:
f.profile.Bypass = []string{value}
}
return
}
if f.field == 1 {
f.profile.SingleURL = value
} else {
f.profile.Bypass = []string{value}
}
}
+118
View File
@@ -0,0 +1,118 @@
package tui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
type recordingModule struct {
snapshot app.Snapshot
intents []app.Intent
}
func (m *recordingModule) Snapshot() app.Snapshot { return m.snapshot }
func (m *recordingModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent)
return app.Result{Snapshot: m.snapshot, Notice: "done"}
}
func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{
ActiveProfile: "office",
Profiles: []profile.Profile{{
Name: "office", Mode: profile.SingleMode,
SingleURL: "https://alice:very-secret@proxy.example:8443",
}},
}}
model := NewModel(module)
if view := model.View(); !strings.Contains(view, "Active Profile: office") {
t.Fatalf("main view missing Active Profile:\n%s", view)
}
model = updateKey(model, "p")
view := model.View()
if !strings.Contains(view, "Proxy Profiles") || !strings.Contains(view, "alice:") {
t.Fatalf("Profile page did not render selected Profile:\n%s", view)
}
if strings.Contains(view, "very-secret") {
t.Fatalf("Profile page exposed proxy password:\n%s", view)
}
model = updateKey(model, "a")
if len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "office" {
t.Fatalf("activate key dispatched %#v", module.intents)
}
model = updateKey(model, "d")
if !strings.Contains(model.View(), "Delete Profile") {
t.Fatalf("delete key did not request confirmation:\n%s", model.View())
}
model = updateKey(model, "y")
if len(module.intents) != 2 || module.intents[1].Kind != app.DeleteProfile || !module.intents[1].Confirm {
t.Fatalf("confirmed delete dispatched %#v", module.intents)
}
}
func TestProfileFormMapsKeyboardInputToCreateIntent(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
model = updateKey(model, "p")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "socks5://localhost:1080")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost, .example.com")
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("create form dispatched %d intents", len(module.intents))
}
intent := module.intents[0]
if intent.Kind != app.CreateProfile || intent.Profile.Name != "home" || intent.Profile.SingleURL != "socks5://localhost:1080" {
t.Fatalf("create form dispatched %#v", intent)
}
if got := intent.Profile.Bypass; len(got) != 1 || got[0] != "localhost, .example.com" {
t.Fatalf("create form Bypass = %#v", got)
}
}
func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
Name: "manual", Mode: profile.SingleMode, SingleURL: "http://alice:do-not-show@proxy.example:bad",
}}}}
model := NewModel(module)
model = updateKey(model, "p")
if view := model.View(); strings.Contains(view, "do-not-show") {
t.Fatalf("malformed proxy URL exposed password:\n%s", view)
}
}
func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) {
model := NewModel(&recordingModule{})
view := model.View()
if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "p: manage Proxy Profiles") {
t.Fatalf("no-Profile main view is not actionable:\n%s", view)
}
}
func updateKey(model Model, key string) Model {
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
switch key {
case "tab":
msg = tea.KeyMsg{Type: tea.KeyTab}
case "ctrl+s":
msg = tea.KeyMsg{Type: tea.KeyCtrlS}
}
updated, _ := model.Update(msg)
return updated.(Model)
}
func updateRunes(model Model, value string) Model {
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)})
return updated.(Model)
}