summaryrefslogtreecommitdiff
path: root/internal/ui/repeater_ws.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui/repeater_ws.go')
-rw-r--r--internal/ui/repeater_ws.go418
1 files changed, 418 insertions, 0 deletions
diff --git a/internal/ui/repeater_ws.go b/internal/ui/repeater_ws.go
new file mode 100644
index 0000000..4a61263
--- /dev/null
+++ b/internal/ui/repeater_ws.go
@@ -0,0 +1,418 @@
+package ui
+
+import (
+ "crypto/tls"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/gorilla/websocket"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+type wsConnState int
+
+const (
+ wsDisconnected wsConnState = iota
+ wsConnecting
+ wsConnected
+)
+
+type wsFrame struct {
+ direction string
+ payload string
+ ts time.Time
+}
+
+type wsHeaderRow struct {
+ key string
+ value string
+}
+
+type wsTabState struct {
+ r *repeaterTab
+ tab store.RepeaterTab
+ tabID int64
+ wsConn *websocket.Conn
+ connState wsConnState
+ connID uint64
+ customHeaders []wsHeaderRow
+ frames []wsFrame
+ frameTable *widgets.DataTable
+ payloadView *widgets.TextView
+ connectBtn *widget.Button
+ sendFrameBtn *widget.Button
+ urlEntry *widget.Entry
+}
+
+func (s *wsTabState) disconnect() {
+ if s.wsConn != nil {
+ s.wsConn.Close()
+ s.wsConn = nil
+ }
+ s.connState = wsDisconnected
+ s.updateConnectBtn()
+}
+
+func (s *wsTabState) updateConnectBtn() {
+ switch s.connState {
+ case wsDisconnected:
+ s.connectBtn.SetText("Connect")
+ s.connectBtn.Importance = widget.HighImportance
+ s.connectBtn.Enable()
+ s.sendFrameBtn.Disable()
+ case wsConnecting:
+ s.connectBtn.SetText("Connecting...")
+ s.connectBtn.Importance = widget.MediumImportance
+ s.connectBtn.Disable()
+ s.sendFrameBtn.Disable()
+ case wsConnected:
+ s.connectBtn.SetText("Disconnect")
+ s.connectBtn.Importance = widget.DangerImportance
+ s.connectBtn.Enable()
+ s.sendFrameBtn.Enable()
+ }
+ s.connectBtn.Refresh()
+}
+
+func (s *wsTabState) startReadLoop(conn *websocket.Conn) {
+ go func() {
+ for {
+ msgType, payload, err := conn.ReadMessage()
+ if err != nil {
+ fyne.Do(func() {
+ if s.wsConn == conn {
+ s.wsConn = nil
+ s.connState = wsDisconnected
+ s.updateConnectBtn()
+ s.frames = append(s.frames, wsFrame{
+ direction: "⚠ Closed",
+ payload: err.Error(),
+ ts: time.Now(),
+ })
+ s.frameTable.Refresh()
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ }
+ })
+ return
+ }
+ finalPayload := payload
+ if s.r.bus != nil {
+ result := s.r.bus.EmitWebSocketFrame(events.WebSocketFrameEvent{
+ ConnectionID: s.connID,
+ Timestamp: time.Now(),
+ Direction: events.WebSocketServer,
+ Opcode: events.WebSocketOpcode(msgType),
+ Payload: payload,
+ })
+ finalPayload = result.Payload
+ }
+ f := wsFrame{direction: "← Recv", payload: string(finalPayload), ts: time.Now()}
+ fyne.Do(func() {
+ s.frames = append(s.frames, f)
+ s.frameTable.Refresh()
+ s.frameTable.ScrollToRow(len(s.frames) - 1)
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ })
+ }
+ }()
+}
+
+func (s *wsTabState) showHeadersDialog() {
+ editHeaders := make([]wsHeaderRow, len(s.customHeaders))
+ copy(editHeaders, s.customHeaders)
+
+ var rows *fyne.Container
+ var rebuildRows func()
+
+ rebuildRows = func() {
+ rows.RemoveAll()
+ for i := range editHeaders {
+ idx := i
+ keyEntry := widget.NewEntry()
+ keyEntry.SetPlaceHolder("Header-Name")
+ keyEntry.SetText(editHeaders[idx].key)
+ keyEntry.OnChanged = func(v string) { editHeaders[idx].key = v }
+
+ valEntry := widget.NewEntry()
+ valEntry.SetPlaceHolder("value")
+ valEntry.SetText(editHeaders[idx].value)
+ valEntry.OnChanged = func(v string) { editHeaders[idx].value = v }
+
+ deleteBtn := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() {
+ editHeaders = append(editHeaders[:idx], editHeaders[idx+1:]...)
+ rebuildRows()
+ })
+
+ rows.Add(container.NewBorder(nil, nil, nil, deleteBtn,
+ container.NewGridWithColumns(2, keyEntry, valEntry),
+ ))
+ }
+ rows.Refresh()
+ }
+
+ rows = container.NewVBox()
+ rebuildRows()
+
+ addBtn := widget.NewButtonWithIcon("Add Header", theme.ContentAddIcon(), func() {
+ editHeaders = append(editHeaders, wsHeaderRow{})
+ rebuildRows()
+ })
+
+ scrolled := container.NewVScroll(rows)
+ scrolled.SetMinSize(fyne.NewSize(500, 240))
+
+ body := container.NewBorder(nil, addBtn, nil, nil, scrolled)
+
+ dialog.ShowCustomConfirm("Edit Request Headers", "Save", "Cancel", body, func(save bool) {
+ if !save {
+ return
+ }
+ s.customHeaders = s.customHeaders[:0]
+ for _, h := range editHeaders {
+ if strings.TrimSpace(h.key) != "" {
+ s.customHeaders = append(s.customHeaders, h)
+ }
+ }
+ }, s.r.win)
+}
+
+func (s *wsTabState) buildFrameTable() {
+ frameColumns := []widgets.DataTableColumn{
+ {Header: "Dir", Width: 80},
+ {Header: "Time", Width: 120},
+ {Header: "Payload", Width: 300},
+ }
+
+ s.frameTable = widgets.NewDataTable()
+ s.frameTable.SetWindow(s.r.win)
+ s.frameTable.Columns = frameColumns
+ s.frameTable.RowCount = func() int { return len(s.frames) }
+ s.frameTable.RowID = func(row int) int64 { return int64(row) }
+ s.frameTable.CellValue = func(row, col int) string {
+ if row >= len(s.frames) {
+ return ""
+ }
+ f := s.frames[row]
+ switch col {
+ case 0:
+ return f.direction
+ case 1:
+ return f.ts.Format("15:04:05.000")
+ case 2:
+ return f.payload
+ }
+ return ""
+ }
+ s.frameTable.CellStyle = func(row, col int) widget.Importance {
+ if col != 0 || row >= len(s.frames) {
+ return widget.MediumImportance
+ }
+ if s.frames[row].direction == "→ Sent" {
+ return widget.HighImportance
+ }
+ return widget.SuccessImportance
+ }
+}
+
+func (s *wsTabState) buildPayloadView() {
+ s.payloadView = widgets.NewTextView()
+ s.payloadView.SetWindow(s.r.win)
+ s.payloadView.SetPlaceHolder("Select a frame to view full payload...")
+
+ s.frameTable.OnSelect = func(row int) {
+ if row >= len(s.frames) {
+ return
+ }
+ p := s.frames[row].payload
+ if p == "" {
+ s.payloadView.SetText("(empty)")
+ } else {
+ s.payloadView.SetText(p)
+ }
+ }
+}
+
+func (s *wsTabState) buildConnectHandler() {
+ s.connectBtn.OnTapped = func() {
+ if s.connState == wsConnected {
+ s.disconnect()
+ return
+ }
+ rawURL := strings.TrimSpace(s.urlEntry.Text)
+ if rawURL == "" {
+ return
+ }
+ s.connState = wsConnecting
+ s.updateConnectBtn()
+ if err := s.r.projectStore.UpdateRepeaterTab(s.tabID, rawURL, ""); err != nil {
+ logger.Error("repeater: update ws tab: %v", err)
+ }
+
+ reqHeader := http.Header{}
+ for _, h := range s.customHeaders {
+ if strings.TrimSpace(h.key) != "" {
+ cleaned := strings.NewReplacer("\n", "", "\r", "").Replace(h.value)
+ reqHeader.Set(h.key, cleaned)
+ }
+ }
+
+ go func() {
+ dialer := websocket.Dialer{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
+ HandshakeTimeout: 10 * time.Second,
+ NetDial: func(network, addr string) (net.Conn, error) {
+ return net.DialTimeout(network, addr, 10*time.Second)
+ },
+ }
+ conn, _, err := dialer.Dial(rawURL, reqHeader)
+ fyne.Do(func() {
+ if err != nil {
+ s.connState = wsDisconnected
+ s.updateConnectBtn()
+ s.frames = append(s.frames, wsFrame{direction: "⚠ Error", payload: err.Error(), ts: time.Now()})
+ s.frameTable.Refresh()
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ return
+ }
+ s.wsConn = conn
+ s.connState = wsConnected
+ s.updateConnectBtn()
+ if s.r.bus != nil {
+ s.connID = s.r.bus.EmitWebSocketConnection(events.WebSocketConnectionEvent{
+ Host: rawURL,
+ URL: rawURL,
+ TLS: strings.HasPrefix(rawURL, "wss://"),
+ Timestamp: time.Now(),
+ })
+ }
+ s.frames = append(s.frames, wsFrame{direction: "✓ Connected", payload: rawURL, ts: time.Now()})
+ s.frameTable.Refresh()
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ s.startReadLoop(conn)
+ })
+ }()
+ }
+}
+
+func (s *wsTabState) buildSendHandler(payloadEntry *widget.Entry) {
+ s.sendFrameBtn.OnTapped = func() {
+ if s.wsConn == nil || s.connState != wsConnected {
+ return
+ }
+ payload := strings.ReplaceAll(strings.ReplaceAll(payloadEntry.Text, "\r\n", ""), "\n", "")
+ if payload == "" {
+ return
+ }
+ conn := s.wsConn
+ go func() {
+ finalPayload := []byte(payload)
+ if s.r.bus != nil {
+ result := s.r.bus.EmitWebSocketFrame(events.WebSocketFrameEvent{
+ ConnectionID: s.connID,
+ Timestamp: time.Now(),
+ Direction: events.WebSocketClient,
+ Opcode: events.WebSocketText,
+ Payload: finalPayload,
+ })
+ finalPayload = result.Payload
+ }
+ err := conn.WriteMessage(websocket.TextMessage, finalPayload)
+ logger.Info("ws: sending payload bytes: %q", finalPayload)
+ fyne.Do(func() {
+ if err != nil {
+ s.frames = append(s.frames, wsFrame{direction: "⚠ Error", payload: "send failed: " + err.Error(), ts: time.Now()})
+ s.frameTable.Refresh()
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ return
+ }
+ s.frames = append(s.frames, wsFrame{direction: "→ Sent", payload: string(finalPayload), ts: time.Now()})
+ s.frameTable.Refresh()
+ s.frameTable.ScrollToRow(len(s.frames) - 1)
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ })
+ }()
+ }
+}
+
+func (s *wsTabState) buildLayout(payloadEntry *widget.Entry, clearBtn, headersBtn *widget.Button) fyne.CanvasObject {
+ toolbar := container.NewBorder(nil, nil,
+ container.NewHBox(s.connectBtn, clearBtn, headersBtn),
+ nil,
+ s.urlEntry,
+ )
+
+ sendBar := container.NewBorder(nil, nil, nil, s.sendFrameBtn, payloadEntry)
+
+ framePane := container.NewBorder(paneHeader("Frames"), nil, nil, nil, s.frameTable.Build())
+ payloadPane := container.NewBorder(paneHeader("Payload"), nil, nil, nil, s.payloadView.Build())
+
+ frameSplit := container.NewVSplit(framePane, payloadPane)
+ frameSplit.SetOffset(0.6)
+
+ return container.NewBorder(
+ container.New(layout.NewCustomPaddedLayout(8, 0, 0, 0), toolbar),
+ container.NewBorder(nil, nil, nil, nil, sendBar),
+ nil, nil,
+ frameSplit,
+ )
+}
+
+func (r *repeaterTab) buildWSTabItem(tab store.RepeaterTab) *container.TabItem {
+ s := &wsTabState{
+ r: r,
+ tab: tab,
+ tabID: tab.ID,
+ }
+
+ s.urlEntry = widget.NewEntry()
+ s.urlEntry.SetPlaceHolder("wss://host:port/path")
+ if tab.RawRequest != "" {
+ s.urlEntry.SetText(tab.RawRequest)
+ }
+
+ s.connectBtn = widget.NewButtonWithIcon("Connect", AppIcon("web"), nil)
+ s.connectBtn.Importance = widget.HighImportance
+
+ s.sendFrameBtn = widget.NewButtonWithIcon("Send", theme.MailSendIcon(), nil)
+ s.sendFrameBtn.Disable()
+
+ s.buildFrameTable()
+ s.buildPayloadView()
+ s.buildConnectHandler()
+
+ payloadEntry := widget.NewMultiLineEntry()
+ payloadEntry.SetPlaceHolder("Message to send...")
+ payloadEntry.SetMinRowsVisible(3)
+
+ s.buildSendHandler(payloadEntry)
+
+ clearBtn := widget.NewButtonWithIcon("Clear", AppIcon("delete"), func() {
+ s.frames = nil
+ s.frameTable.Refresh()
+ s.payloadView.SetText("")
+ })
+
+ headersBtn := widget.NewButtonWithIcon("Headers", theme.ListIcon(), func() {
+ s.showHeadersDialog()
+ })
+
+ content := s.buildLayout(payloadEntry, clearBtn, headersBtn)
+ tabItem := container.NewTabItem(tab.Name, content)
+ r.tabIDs[tabItem] = s.tabID
+ r.loadFns[tabItem] = func() {}
+
+ return tabItem
+}