diff options
| author | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
|---|---|---|
| committer | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
| commit | 0a477fd288d064d265395c88dbd0f261b723804e (patch) | |
| tree | 9b129f8f41c907285b89ed8385679934bb59c14f /internal/ui/intercept.go | |
Initial release
Diffstat (limited to 'internal/ui/intercept.go')
| -rw-r--r-- | internal/ui/intercept.go | 216 |
1 files changed, 216 insertions, 0 deletions
diff --git a/internal/ui/intercept.go b/internal/ui/intercept.go new file mode 100644 index 0000000..4ce8e74 --- /dev/null +++ b/internal/ui/intercept.go @@ -0,0 +1,216 @@ +package ui + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/http" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/shiv/internal/logger" + "github.com/shiv/internal/store" +) + +type interceptTab struct { + projectStore *store.Store + pending *store.PendingRequest + + toggle *widget.Check + editor *widget.Entry + forward *widget.Button + drop *widget.Button + forwardAll *widget.Button +} + +func newInterceptTab(projectStore *store.Store) *interceptTab { + return &interceptTab{projectStore: projectStore} +} + +func (t *interceptTab) build() fyne.CanvasObject { + t.toggle = widget.NewCheck("Intercept ON", func(on bool) { + t.projectStore.Intercept.SetEnabled(on) + if !on { + // SetEnabled(false) triggers bypass internally — all goroutines + // blocked in Hold are released. We only need to handle whatever + // the UI is currently displaying. + if t.pending != nil { + old := t.pending + t.pending = nil + old.Reply <- store.Decision{Forward: true, Request: old.Request, Body: old.Body} + } + t.clearEditor() + } + }) + + t.editor = widget.NewMultiLineEntry() + t.editor.SetPlaceHolder("No request intercepted yet.") + t.editor.TextStyle = fyne.TextStyle{Monospace: true} + t.editor.Wrapping = fyne.TextWrapOff + t.editor.Disable() + + t.forward = widget.NewButtonWithIcon("Forward", theme.ConfirmIcon(), func() { + t.decide(true) + }) + t.drop = widget.NewButtonWithIcon("Drop", theme.DeleteIcon(), func() { + t.decide(false) + }) + t.forwardAll = widget.NewButtonWithIcon("Forward All", theme.MediaFastForwardIcon(), func() { + // Forward whatever is currently displayed, then signal the gate to + // release all goroutines currently blocked in Hold. Future requests + // will be intercepted normally — ForwardAll is a one-shot release. + if t.pending != nil { + old := t.pending + t.pending = nil + old.Reply <- store.Decision{Forward: true, Request: old.Request, Body: old.Body} + } + t.clearEditor() + t.projectStore.Intercept.ForwardAll() + }) + + t.forward.Importance = widget.HighImportance + t.forward.Disable() + t.drop.Disable() + t.forwardAll.Disable() + + scopeOnly := widget.NewCheck("Scope only", func(on bool) { + if on { + t.projectStore.Intercept.SetScopeFilter(t.projectStore.InScope) + } else { + t.projectStore.Intercept.SetScopeFilter(nil) + } + }) + + copyBtn := widget.NewButton("Copy", func() { + fyne.CurrentApp().Clipboard().SetContent(t.editor.Text) + }) + + buttons := container.NewHBox(t.forward, t.drop, t.forwardAll) + + content := container.NewBorder( + container.NewVBox( + container.NewBorder(nil, nil, + container.NewHBox(t.toggle, scopeOnly), + copyBtn, nil), + widget.NewSeparator(), + ), + buttons, + nil, nil, + t.editor, + ) + + go t.watchQueue() + + return content +} + +// watchQueue reads from the gate queue and presents requests to the UI one at +// a time. Cancelled pending (released by bypass before the UI processed them) +// are skipped via the double IsDone check — once on the watchQueue goroutine +// and once inside fyne.Do on the main goroutine. +func (t *interceptTab) watchQueue() { + for pending := range t.projectStore.Intercept.Queue() { + p := pending + // Fast path: already cancelled before we even schedule fyne.Do. + if p.IsDone() { + continue + } + fyne.Do(func() { + // Second check: may have been cancelled between the fast path above + // and this callback executing on the main goroutine. + if p.IsDone() { + return + } + t.showRequest(p) + }) + } +} + +func formatLiveRequest(req *http.Request, body []byte) string { + var builder bytes.Buffer + path := req.URL.RequestURI() + if path == "" { + path = "/" + } + fmt.Fprintf(&builder, "%s %s HTTP/1.1\r\n", req.Method, path) + fmt.Fprintf(&builder, "Host: %s\r\n", req.Host) + for headerKey, headerValues := range req.Header { + for _, headerValue := range headerValues { + fmt.Fprintf(&builder, "%s: %s\r\n", headerKey, headerValue) + } + } + builder.WriteString("\r\n") + if len(body) > 0 { + builder.Write(body) + } + return builder.String() +} + +func (t *interceptTab) showRequest(p *store.PendingRequest) { + t.pending = p + t.editor.Enable() + t.editor.SetText(formatLiveRequest(p.Request, p.Body)) + t.forward.Enable() + t.drop.Enable() + t.forwardAll.Enable() +} + +func (t *interceptTab) decide(forward bool) { + if t.pending == nil { + return + } + pending := t.pending + rawText := t.editor.Text + t.pending = nil + t.clearEditor() + + if forward { + req, body, err := parseRawRequest(rawText, pending.Request) + if err != nil { + logger.Error("intercept: parse edited request: %v — forwarding original", err) + req = pending.Request + body = pending.Body + } + pending.Reply <- store.Decision{Forward: true, Request: req, Body: body} + } else { + pending.Reply <- store.Decision{Forward: false, Request: pending.Request, Body: pending.Body} + } +} + +func (t *interceptTab) clearEditor() { + t.editor.SetText("") + t.editor.Disable() + t.forward.Disable() + t.drop.Disable() + t.forwardAll.Disable() +} + +func parseRawRequest(raw string, original *http.Request) (*http.Request, []byte, error) { + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(raw))) + if err != nil { + return nil, nil, err + } + + req.URL.Scheme = original.URL.Scheme + req.URL.Host = original.URL.Host + if req.Host == "" { + req.Host = original.Host + } + + var body []byte + if req.Body != nil { + var err error + body, err = io.ReadAll(req.Body) + req.Body.Close() + if err != nil { + return nil, nil, fmt.Errorf("read request body: %w", err) + } + } + req.Body = http.NoBody + + return req, body, nil +} |
