summaryrefslogtreecommitdiff
path: root/internal/ui/intercept.go
diff options
context:
space:
mode:
authorspiperac <[email protected]>2026-06-15 06:42:13 +0200
committerspiperac <[email protected]>2026-06-15 06:42:13 +0200
commit0a477fd288d064d265395c88dbd0f261b723804e (patch)
tree9b129f8f41c907285b89ed8385679934bb59c14f /internal/ui/intercept.go
Initial release
Diffstat (limited to 'internal/ui/intercept.go')
-rw-r--r--internal/ui/intercept.go216
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
+}