Files
proxy/internal/tui/model.go
T

711 lines
20 KiB
Go

package tui
import (
"context"
"errors"
"strings"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/probe"
"proxyctl/internal/profile"
)
type page int
const (
mainPage page = iota
profilesPage
formPage
helpPage
)
type formState struct {
originalName string
profile profile.Profile
field int
probing bool
probeGen uint64
}
type Model struct {
application app.InteractiveModule
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
driftResolutionTargetID string
form formState
confirm bool
status string
operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
}
// probeDone carries one finished Protocol Probe back to the form. address is
// always bare host:port so status text can never leak credentials. gen matches
// the probe generation stored in formState so late results can be ignored after
// the user leaves the form.
type probeDone struct {
address string
caps probe.Capabilities
err error
gen uint64
}
func NewModel(application app.InteractiveModule, configure ...ModelOption) Model {
options := defaultModelOptions()
for _, option := range configure {
option(&options)
}
snapshot := application.Snapshot()
model := Model{
application: application,
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
snapshot: snapshot,
selectedTargetID: newTargetCatalog(snapshot, false, "").firstID(),
width: 80,
height: 24,
operation: newOperationCoordinator(),
detect: probe.Detect,
}
if targetsChecking(snapshot) {
model.startOperation(app.Intent{Kind: app.RefreshTargets})
}
return model
}
func targetsChecking(snapshot app.Snapshot) bool {
for _, item := range snapshot.Targets {
if item.DetectionPhase == app.TargetChecking {
return true
}
}
return false
}
func (m Model) Init() tea.Cmd {
if m.operation.updates != nil {
return waitForOperation(m.operation.updates)
}
return nil
}
// busy is the single-operation guard for asynchronous Application work. Every
// operation start checks it, so a completion can only belong to the one current
// operation rather than racing another operation for shared model state.
func (m Model) busy() bool { return m.operation.busy() }
func (m Model) operationInFlight() bool { return m.operation.active }
func (m Model) dashboardNavigationAvailable() bool {
// A deferred exit rejects new work, not read-only browsing. Selection,
// details, and Help remain useful until the current mutation is safe.
return true
}
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
// second mutation.
if m.operation.quitRequested && !m.operationInFlight() {
return m, nil
}
if key, ok := message.(tea.KeyMsg); ok {
// Dashboard q always means exit, even when confirmation is visible.
// Escape remains the deterministic cancel/back key.
exitRequest := key.String() == "ctrl+c" || (m.page == mainPage && key.String() == "q")
if exitRequest {
return m, m.operation.requestQuit()
}
}
if progress, ok := message.(operationProgress); ok {
if !m.operation.active || progress.updates != m.operation.updates {
return m, nil
}
if progress.closed {
m.snapshot = m.application.Snapshot()
m.retainTargetSelection()
m.operation.unexpectedClose()
return m, m.operation.deferredQuit()
}
m.snapshot = progress.update.Snapshot
m.retainTargetSelection()
if firstFailed := m.operation.apply(progress.update); firstFailed != "" {
m.selectedTargetID = firstFailed
m.detailsOffset = 0
}
if m.operation.confirmation != nil {
m.page = mainPage
}
if progress.update.IsTerminal() {
return m, m.operation.deferredQuit()
}
return m, waitForOperation(progress.updates)
}
if done, ok := message.(probeDone); ok {
if !m.form.probing || done.gen != m.form.probeGen {
return m, nil
}
m.form.probing = false
switch {
case done.err != nil:
m.status = "Protocol Probe: no proxy detected at " + done.address + ": " + done.err.Error()
case !done.caps.HTTP && !done.caps.SOCKS5:
m.status = "Protocol Probe: unrecognized protocol at " + done.address
default:
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
}
m.form.profile.SingleURL = rewritten
kind := "SOCKS5"
if done.caps.HTTP {
kind = "HTTP"
}
m.status = "Protocol Probe: detected " + kind + " proxy at " + done.address
}
return m, nil
}
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)
case helpPage:
if key.String() == "q" || key.String() == "esc" {
m.page = mainPage
}
return m, nil
default:
return m, nil
}
}
func (m *Model) startOperation(intent app.Intent) tea.Cmd {
snapshot, command, started := m.operation.start(m.application, intent)
if started {
m.snapshot = snapshot
m.retainTargetSelection()
}
return command
}
type mutationAvailability struct {
available bool
reason string
compactReason string
}
// targetMutationAvailability consumes the Application's structured action
// policy. It adds only Model-owned serialization state and never derives an
// action from Target state or display text.
func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, operationBlocked bool) mutationAvailability {
switch {
case operationBlocked:
return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"}
case !selectedOK:
return mutationAvailability{reason: "Target mutation is disabled because no Target is selected", compactReason: "no Target selected"}
case !selected.PrimaryAction.Available:
reason := selected.PrimaryAction.DisabledReason
if reason == "" {
reason = "This Target action is unavailable"
}
return mutationAvailability{reason: reason, compactReason: "unavailable"}
default:
return mutationAvailability{available: true}
}
}
func driftResolutionAction(selected app.TargetSnapshot, kind app.TargetActionKind) (app.TargetAction, bool) {
for _, action := range selected.DriftResolutionActions {
if action.Kind == kind {
return action, true
}
}
return app.TargetAction{Kind: kind}, false
}
func targetIntent(kind app.TargetActionKind, targetID string) (app.Intent, bool) {
intent := app.Intent{TargetID: targetID}
switch kind {
case app.EnableTargetAction:
intent.Kind = app.EnableTarget
case app.DisableTargetAction:
intent.Kind = app.DisableTarget
case app.ApplyProfileTargetAction:
intent.Kind = app.ApplyProfileToTarget
case app.ReapplyActiveProfileAction:
intent.Kind = app.ReapplyActiveProfile
case app.RestoreOriginalAction:
intent.Kind = app.RestoreOriginal
default:
return app.Intent{}, false
}
return intent, true
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.operation.confirmation != nil {
switch key.String() {
case "y":
confirmed, ok := m.operation.confirmedIntent()
if !ok {
return m, nil
}
m.status = ""
return m, m.startOperation(confirmed)
case "enter", "n", "esc":
m.operation.cancelConfirmation()
}
return m, nil
}
if m.driftResolutionTargetID != "" {
switch key.String() {
case "enter", "esc", "n":
m.driftResolutionTargetID = ""
return m, nil
case "r", "o":
kind := app.ReapplyActiveProfileAction
if key.String() == "o" {
kind = app.RestoreOriginalAction
}
selected, ok := newTargetCatalog(m.snapshot, true, m.driftResolutionTargetID).selected(m.driftResolutionTargetID)
if !ok {
m.driftResolutionTargetID = ""
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Drift Resolution is unavailable because the Target is no longer present"}
return m, nil
}
action, ok := driftResolutionAction(selected, kind)
if !ok || !action.Available {
reason := action.DisabledReason
if reason == "" {
reason = "This Drift Resolution action is unavailable"
}
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: reason}
return m, nil
}
intent, _ := targetIntent(kind, selected.ID)
m.driftResolutionTargetID = ""
return m, m.startOperation(intent)
}
return m, nil
}
if key.String() == "e" && m.operation.currentError != nil {
m.operation.errorExpanded = !m.operation.errorExpanded
m.operation.errorOffset = 0
return m, nil
}
if key.String() == "x" && m.operation.currentError != nil {
m.operation.acknowledgeError()
return m, nil
}
if m.operation.errorExpanded {
switch key.String() {
case "pgup":
m.operation.errorOffset = max(0, m.operation.errorOffset-5)
return m, nil
case "pgdown":
m.operation.errorOffset += 5
return m, nil
}
}
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
switch key.String() {
case "p":
if m.operation.active || m.operation.quitRequested {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before opening Profile management"}
} else {
m.page = profilesPage
m.selected = 0
m.status = ""
if m.operation.status.Kind != app.StatusError {
m.operation.status = app.OperationStatus{}
}
}
case "?":
m.page = helpPage
case "r":
if m.operation.active || m.operation.quitRequested {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before refreshing Targets"}
} else {
return m, m.startOperation(app.Intent{Kind: app.RefreshTargets})
}
case "tab":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetListPane {
m.dashboardFocus = targetDetailsPane
} else {
m.dashboardFocus = targetListPane
}
}
case "u":
if m.dashboardNavigationAvailable() {
formerIndex := catalog.index(m.selectedTargetID)
m.showUnavailable = !m.showUnavailable
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
m.detailsOffset = 0
}
case "up", "k":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-1)
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, -1)
m.detailsOffset = 0
}
}
case "down", "j":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset++
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, 1)
m.detailsOffset = 0
}
}
case "pgup":
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-5)
}
case "pgdown":
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
m.detailsOffset += 5
}
case " ":
selected, ok := catalog.selected(m.selectedTargetID)
availability := targetMutationAvailability(selected, ok, m.operation.active || m.operation.quitRequested)
if !availability.available {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: availability.reason}
break
}
if selected.PrimaryAction.Kind == app.ResolveDriftTargetAction {
m.driftResolutionTargetID = selected.ID
break
}
intent, ok := targetIntent(selected.PrimaryAction.Kind, selected.ID)
if !ok {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "This Target action is unavailable"}
break
}
return m, m.startOperation(intent)
}
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
}
if m.busy() {
if key.String() == "q" || key.String() == "esc" {
m.page = mainPage
m.selected = 0
m.status = ""
}
return m, nil
}
switch key.String() {
case "q", "esc":
m.page = mainPage
m.selected = 0
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 {
return m.beginProfileActivation(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 = ""
m.form = formState{}
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()
m.form.probeGen++ // discard any in-flight probe result while staying on the form
m.form.probing = false
return m, nil
}
m.status = result.Notice
m.page = profilesPage
m.selectName(intent.Profile.Name)
m.form = formState{}
return m, nil
case "ctrl+d":
if m.form.profile.Mode != profile.SingleMode {
m.status = "Protocol Probe: available in Single mode only"
return m, nil
}
if m.form.field != 1 || m.form.probing {
return m, nil
}
address, err := probeAddress(m.form.profile.SingleURL)
if err != nil {
m.status = "Protocol Probe: " + err.Error()
return m, nil
}
m.form.probing = true
m.form.probeGen++
m.status = "Protocol Probe: checking " + address + "…"
detect := m.detect
gen := m.form.probeGen
return m, func() tea.Msg {
caps, err := detect(context.Background(), address)
return probeDone{address: address, caps: caps, err: err, gen: gen}
}
case "ctrl+t":
if m.form.profile.Mode == profile.SingleMode {
m.form.profile.Mode = profile.AdvancedMode
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) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
if m.busy() {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"}
return m, nil
}
m.page = mainPage
m.selected = 0
return m, m.startOperation(intent)
}
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
}
}
// probeAddress extracts a bare host:port from the typed endpoint for dialing
// and status text, dropping any userinfo so credentials are never displayed
// or sent anywhere.
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 (m Model) selectedProfile() (profile.Profile, bool) {
return profileAt(m.snapshot, m.selected)
}
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 {
return m.presentation.Render(presentationState{
snapshot: m.snapshot,
page: m.page,
width: m.width,
height: m.height,
selected: m.selected,
selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
driftResolutionTargetID: m.driftResolutionTargetID,
form: m.form,
confirm: m.confirm,
confirmation: m.operation.confirmation,
status: m.status,
operationStatus: m.operation.status,
currentError: m.operation.currentError,
errorExpanded: m.operation.errorExpanded,
errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
operationActive: m.operation.active,
operationProgress: m.operation.progress,
})
}
func (m Model) availableTargets() []app.TargetSnapshot {
return availableTargets(m.snapshot)
}
func (m *Model) retainTargetSelection() {
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
}
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}
}
}