package tui import ( "context" "errors" "strings" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" "proxyctl/internal/probe" "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 probing bool probeGen uint64 } type Model struct { application app.Module presentation terminalPresentation snapshot app.Snapshot page page width int height int selected int selectedTargetID string dashboardFocus dashboardPane showUnavailable bool detailsOffset int form formState confirm bool targetConfirm *app.Intent profileConfirm *app.Intent pendingActivation app.Intent status string applying bool refreshing bool detectionUpdates <-chan app.TargetDetectionUpdate profileTransition bool progressTarget string quitRequested bool detect func(context.Context, string) (probe.Capabilities, error) } type targetOperationDone struct { result app.Result } type refreshDone struct { result app.Result } type targetDetectionProgress struct { update app.TargetDetectionUpdate updates <-chan app.TargetDetectionUpdate } type profileOperationProgress struct { result app.Result updates <-chan app.Result } // 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 waitForTargetDetection(updates <-chan app.TargetDetectionUpdate) tea.Cmd { return func() tea.Msg { update, ok := <-updates if !ok { return targetDetectionProgress{update: app.TargetDetectionUpdate{Complete: true}, updates: updates} } return targetDetectionProgress{update: update, updates: updates} } } 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, configure ...ModelOption) Model { options := defaultModelOptions() for _, option := range configure { option(&options) } snapshot := application.Snapshot() var detectionUpdates <-chan app.TargetDetectionUpdate if progressive, ok := application.(app.ProgressiveDetectionModule); ok { snapshot, detectionUpdates = progressive.BeginTargetDetection() } return Model{ application: application, presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)}, snapshot: snapshot, selectedTargetID: newTargetCatalog(snapshot, false).firstID(), width: 80, height: 24, refreshing: detectionUpdates != nil, detectionUpdates: detectionUpdates, detect: probe.Detect, } } func (m Model) Init() tea.Cmd { if m.detectionUpdates != nil { return waitForTargetDetection(m.detectionUpdates) } 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) dashboardNavigationAvailable() bool { return !m.quitRequested && !m.applying && !m.profileTransition } 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.quitRequested && !m.operationInFlight() { return m, nil } if key, ok := message.(tea.KeyMsg); ok && m.operationInFlight() && isQuitKey(key) { m.quitRequested = true return m, nil } if progress, ok := message.(targetDetectionProgress); ok { if !m.refreshing || progress.updates != m.detectionUpdates { return m, nil } if progress.update.Result.TargetID != "" { m.applyTargetDetection(progress.update.Result) } if progress.update.Complete { m.refreshing = false m.detectionUpdates = nil m.status = "Target availability and state checks completed" return m, m.deferredQuit() } return m, waitForTargetDetection(progress.updates) } if completed, ok := message.(refreshDone); ok { if !m.refreshing { return m, nil } m.refreshing = false formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable) formerIndex := formerCatalog.index(m.selectedTargetID) m.snapshot = completed.result.Snapshot m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex) if completed.result.Err != nil { m.status = "Error: " + completed.result.Err.Error() } else { m.status = completed.result.Notice } return m, m.deferredQuit() } if progress, ok := message.(profileOperationProgress); ok { if !m.profileTransition { return m, nil } m.snapshot = progress.result.Snapshot m.retainTargetSelection() 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 = "Error: " + progress.result.Err.Error() } 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 m.retainTargetSelection() 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 = "Error: " + completed.result.Err.Error() } else { m.targetConfirm = nil m.status = completed.result.Notice } return m, m.deferredQuit() } 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 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 } catalog := newTargetCatalog(m.snapshot, m.showUnavailable) 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…" if progressive, ok := m.application.(app.ProgressiveDetectionModule); ok { m.snapshot, m.detectionUpdates = progressive.BeginTargetDetection() m.retainTargetSelection() return m, waitForTargetDetection(m.detectionUpdates) } return m, func() tea.Msg { return refreshDone{result: m.application.Dispatch(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).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 " ": if !m.busy() { selected, ok := catalog.selected(m.selectedTargetID) if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady { break } 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 = "" 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() { 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 } } // 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, 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 { return availableTargets(m.snapshot) } func (m *Model) retainTargetSelection() { catalog := newTargetCatalog(m.snapshot, m.showUnavailable) m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID)) } func (m *Model) applyTargetDetection(result app.TargetDetectionResult) { formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable) formerIndex := formerCatalog.index(m.selectedTargetID) for i := range m.snapshot.Targets { if m.snapshot.Targets[i].ID != result.TargetID { continue } m.snapshot.Targets[i].DetectionPhase = result.Phase m.snapshot.Targets[i].DetectionErr = result.Err m.snapshot.Targets[i].Available = result.Availability.Available m.snapshot.Targets[i].AvailabilityEvidence = result.Availability.Evidence m.snapshot.Targets[i].State = result.Inspection.State m.snapshot.Targets[i].Evidence = result.Inspection.Evidence break } m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex) } 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} } }