659 lines
17 KiB
Go
659 lines
17 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"proxyctl/internal/app"
|
|
"proxyctl/internal/profile"
|
|
"proxyctl/internal/target"
|
|
)
|
|
|
|
type page int
|
|
|
|
const (
|
|
mainPage page = iota
|
|
profilesPage
|
|
formPage
|
|
helpPage
|
|
)
|
|
|
|
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
|
|
targetConfirm *app.Intent
|
|
profileConfirm *app.Intent
|
|
pendingActivation app.Intent
|
|
status string
|
|
applying bool
|
|
refreshing bool
|
|
profileTransition bool
|
|
progressTarget string
|
|
quitRequested bool
|
|
}
|
|
|
|
type targetOperationDone struct {
|
|
result app.Result
|
|
}
|
|
|
|
type refreshDone struct {
|
|
result app.Result
|
|
}
|
|
|
|
type profileOperationProgress struct {
|
|
result app.Result
|
|
updates <-chan app.Result
|
|
}
|
|
|
|
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
|
return func() tea.Msg {
|
|
result, ok := <-updates
|
|
if !ok {
|
|
return profileOperationProgress{result: app.Result{Complete: true}, updates: updates}
|
|
}
|
|
return profileOperationProgress{result: result, updates: updates}
|
|
}
|
|
}
|
|
|
|
func NewModel(application app.Module) Model {
|
|
return Model{application: application, snapshot: application.Snapshot()}
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd { 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.quitRequested || m.operationInFlight() || m.pendingActivation.Kind != 0
|
|
}
|
|
|
|
func (m Model) operationInFlight() bool {
|
|
return m.applying || m.refreshing || m.profileTransition
|
|
}
|
|
|
|
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|
// 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.quitRequested && !m.operationInFlight() {
|
|
return m, nil
|
|
}
|
|
if key, ok := message.(tea.KeyMsg); ok && m.operationInFlight() && isQuitKey(key) {
|
|
m.quitRequested = true
|
|
return m, nil
|
|
}
|
|
if completed, ok := message.(refreshDone); ok {
|
|
if !m.refreshing {
|
|
return m, nil
|
|
}
|
|
m.refreshing = false
|
|
m.snapshot = completed.result.Snapshot
|
|
if completed.result.Err != nil {
|
|
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
|
|
} else {
|
|
m.status = completed.result.Notice
|
|
}
|
|
targetCount := len(m.availableTargets())
|
|
if targetCount == 0 {
|
|
m.selected = 0
|
|
} else if m.selected < 0 {
|
|
m.selected = 0
|
|
} else if m.selected >= targetCount {
|
|
m.selected = targetCount - 1
|
|
}
|
|
return m, m.deferredQuit()
|
|
}
|
|
if progress, ok := message.(profileOperationProgress); ok {
|
|
if !m.profileTransition {
|
|
return m, nil
|
|
}
|
|
m.snapshot = progress.result.Snapshot
|
|
m.progressTarget = ""
|
|
if len(progress.result.Progress) > 0 {
|
|
item := progress.result.Progress[len(progress.result.Progress)-1]
|
|
if item.Applying {
|
|
m.progressTarget = item.TargetID
|
|
}
|
|
}
|
|
if progress.result.Confirmation != "" {
|
|
m.applying = false
|
|
m.profileTransition = false
|
|
m.page = profilesPage
|
|
intent := m.pendingActivation
|
|
m.profileConfirm = &intent
|
|
m.status = progress.result.Confirmation
|
|
return m, m.deferredQuit()
|
|
}
|
|
if progress.result.Err != nil {
|
|
m.status = "\x1b[31mError: " + progress.result.Err.Error() + "\x1b[0m"
|
|
} else if progress.result.Notice != "" {
|
|
m.status = progress.result.Notice
|
|
}
|
|
if progress.result.Complete {
|
|
m.applying = false
|
|
m.profileTransition = false
|
|
m.progressTarget = ""
|
|
m.pendingActivation = app.Intent{}
|
|
return m, m.deferredQuit()
|
|
}
|
|
return m, waitForProfileProgress(progress.updates)
|
|
}
|
|
if completed, ok := message.(targetOperationDone); ok {
|
|
if !m.applying || m.profileTransition {
|
|
return m, nil
|
|
}
|
|
m.applying = false
|
|
m.snapshot = completed.result.Snapshot
|
|
if completed.result.Confirmation != "" {
|
|
intent := app.Intent{Kind: app.SetTargetDesired}
|
|
if m.targetConfirm != nil {
|
|
intent = *m.targetConfirm
|
|
}
|
|
m.targetConfirm = &intent
|
|
m.status = completed.result.Confirmation
|
|
} else if completed.result.Err != nil {
|
|
m.targetConfirm = nil
|
|
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
|
|
} else {
|
|
m.targetConfirm = nil
|
|
m.status = completed.result.Notice
|
|
}
|
|
return m, m.deferredQuit()
|
|
}
|
|
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 isQuitKey(key tea.KeyMsg) bool {
|
|
switch key.String() {
|
|
case "q", "esc", "ctrl+c":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (m Model) deferredQuit() tea.Cmd {
|
|
if m.quitRequested {
|
|
return tea.Quit
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.targetConfirm != nil && !m.busy() {
|
|
switch key.String() {
|
|
case "y":
|
|
intent := *m.targetConfirm
|
|
intent.Confirm = true
|
|
m.targetConfirm = nil
|
|
m.applying = true
|
|
m.status = ""
|
|
return m, func() tea.Msg { return targetOperationDone{result: m.application.Dispatch(intent)} }
|
|
case "n", "esc", "q":
|
|
m.targetConfirm = nil
|
|
m.status = ""
|
|
}
|
|
return m, nil
|
|
}
|
|
targets := m.availableTargets()
|
|
switch key.String() {
|
|
case "p":
|
|
if !m.busy() {
|
|
m.page = profilesPage
|
|
m.selected = 0
|
|
m.status = ""
|
|
}
|
|
case "?":
|
|
if !m.busy() {
|
|
m.page = helpPage
|
|
m.status = ""
|
|
}
|
|
case "r":
|
|
if !m.busy() {
|
|
m.refreshing = true
|
|
m.status = "Refreshing Target availability and state…"
|
|
return m, func() tea.Msg {
|
|
return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})}
|
|
}
|
|
}
|
|
case "up", "k":
|
|
if !m.busy() && m.selected > 0 {
|
|
m.selected--
|
|
}
|
|
case "down", "j":
|
|
if !m.busy() && m.selected+1 < len(targets) {
|
|
m.selected++
|
|
}
|
|
case " ":
|
|
if !m.busy() && m.selected >= 0 && m.selected < len(targets) {
|
|
selected := targets[m.selected]
|
|
desired := selected.State == target.Disabled || selected.State == target.External
|
|
m.applying = true
|
|
m.status = ""
|
|
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
|
|
m.targetConfirm = &intent
|
|
return m, func() tea.Msg {
|
|
return targetOperationDone{result: m.application.Dispatch(intent)}
|
|
}
|
|
}
|
|
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.profileConfirm != nil {
|
|
switch key.String() {
|
|
case "y":
|
|
intent := *m.profileConfirm
|
|
intent.Confirm = true
|
|
m.profileConfirm = nil
|
|
m.pendingActivation = app.Intent{}
|
|
return m.beginProfileActivation(intent)
|
|
case "n", "esc", "q":
|
|
m.profileConfirm = nil
|
|
m.pendingActivation = app.Intent{}
|
|
m.status = ""
|
|
}
|
|
return m, nil
|
|
}
|
|
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 = ""
|
|
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
|
|
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() {
|
|
return m, nil
|
|
}
|
|
if progressive, ok := m.application.(app.ProgressiveModule); ok {
|
|
m.applying = true
|
|
m.profileTransition = true
|
|
m.pendingActivation = intent
|
|
m.page = mainPage
|
|
m.selected = 0
|
|
m.status = "Changing Active Profile…"
|
|
updates := progressive.DispatchProgress(intent)
|
|
return m, waitForProfileProgress(updates)
|
|
}
|
|
m.apply(intent)
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) apply(intent app.Intent) {
|
|
result := m.application.Dispatch(intent)
|
|
m.snapshot = result.Snapshot
|
|
if result.Confirmation != "" {
|
|
m.profileConfirm = &intent
|
|
m.status = result.Confirmation
|
|
return
|
|
}
|
|
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()
|
|
case helpPage:
|
|
return m.helpView()
|
|
default:
|
|
return m.mainView()
|
|
}
|
|
}
|
|
|
|
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 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}
|
|
}
|
|
}
|