summaryrefslogtreecommitdiff
path: root/internal/ui/websocket.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui/websocket.go')
-rw-r--r--internal/ui/websocket.go379
1 files changed, 379 insertions, 0 deletions
diff --git a/internal/ui/websocket.go b/internal/ui/websocket.go
new file mode 100644
index 0000000..9251cac
--- /dev/null
+++ b/internal/ui/websocket.go
@@ -0,0 +1,379 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+var wsWin fyne.Window
+
+func showWebSocketWindow(fyneApp fyne.App, projectStore *store.Store, parentWin fyne.Window, repeater *repeaterTab) {
+ if wsWin != nil {
+ wsWin.RequestFocus()
+ return
+ }
+ wsWin = fyneApp.NewWindow("WebSockets")
+ wsWin.Resize(fyne.NewSize(1100, 650))
+ wsWin.SetOnClosed(func() { wsWin = nil })
+ closeOnEscape(wsWin, wsWin.Close)
+ wsWin.SetContent(buildWebSocketContent(projectStore, wsWin, repeater))
+ wsWin.Show()
+}
+
+func filterConnections(all []store.WebSocketConnection, query string) []store.WebSocketConnection {
+ q := strings.ToLower(strings.TrimSpace(query))
+ if q == "" {
+ out := make([]store.WebSocketConnection, len(all))
+ copy(out, all)
+ return out
+ }
+ terms := strings.Fields(q)
+ var out []store.WebSocketConnection
+ for _, c := range all {
+ s := strings.ToLower(c.Host + " " + c.URL)
+ ok := true
+ for _, t := range terms {
+ if !strings.Contains(s, t) {
+ ok = false
+ break
+ }
+ }
+ if ok {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+func filterFrames(all []store.WebSocketFrame, query string) []store.WebSocketFrame {
+ q := strings.ToLower(strings.TrimSpace(query))
+ if q == "" {
+ out := make([]store.WebSocketFrame, len(all))
+ copy(out, all)
+ return out
+ }
+ terms := strings.Fields(q)
+ var out []store.WebSocketFrame
+ for _, f := range all {
+ s := strings.ToLower(string(f.Payload) + " " + opcodeLabel(f.Opcode))
+ ok := true
+ for _, t := range terms {
+ if !strings.Contains(s, t) {
+ ok = false
+ break
+ }
+ }
+ if ok {
+ out = append(out, f)
+ }
+ }
+ return out
+}
+
+func buildConnTable(win fyne.Window, filteredConns *[]store.WebSocketConnection) (*widgets.DataTable, fyne.CanvasObject) {
+ connColumns := []widgets.DataTableColumn{
+ {Header: "ID", Width: 50},
+ {Header: "Host", Width: 220},
+ {Header: "URL", Width: 380},
+ {Header: "TLS", Width: 45},
+ {Header: "Frames", Width: 70},
+ {Header: "Time", Width: 180},
+ }
+ connTable := widgets.NewDataTable()
+ connTable.SetWindow(win)
+ connTable.Columns = connColumns
+ connTable.RowCount = func() int { return len(*filteredConns) }
+ connTable.RowID = func(row int) int64 {
+ if row >= len(*filteredConns) {
+ return 0
+ }
+ return int64((*filteredConns)[row].ID)
+ }
+ connTable.CellValue = func(row, col int) string {
+ if row >= len(*filteredConns) {
+ return ""
+ }
+ c := (*filteredConns)[row]
+ switch col {
+ case 0:
+ return fmt.Sprintf("%d", c.ID)
+ case 1:
+ return c.Host
+ case 2:
+ return c.URL
+ case 3:
+ if c.TLS {
+ return "✓"
+ }
+ return ""
+ case 4:
+ return fmt.Sprintf("%d", c.FrameCount)
+ case 5:
+ return c.Timestamp.Local().Format("2006-01-02 15:04:05")
+ }
+ return ""
+ }
+ return connTable, connTable.Build()
+}
+
+func buildFrameTable(win fyne.Window, filteredFrames *[]store.WebSocketFrame) (*widgets.DataTable, fyne.CanvasObject) {
+ frameColumns := []widgets.DataTableColumn{
+ {Header: "Dir", Width: 90},
+ {Header: "Type", Width: 70},
+ {Header: "Length", Width: 80},
+ {Header: "Time", Width: 150},
+ }
+ frameTable := widgets.NewDataTable()
+ frameTable.SetWindow(win)
+ frameTable.Columns = frameColumns
+ frameTable.RowCount = func() int { return len(*filteredFrames) }
+ frameTable.RowID = func(row int) int64 {
+ if row >= len(*filteredFrames) {
+ return 0
+ }
+ return int64((*filteredFrames)[row].ID)
+ }
+ frameTable.CellValue = func(row, col int) string {
+ if row >= len(*filteredFrames) {
+ return ""
+ }
+ f := (*filteredFrames)[row]
+ switch col {
+ case 0:
+ if f.Direction == events.WebSocketClient {
+ return "→ Client"
+ }
+ return "← Server"
+ case 1:
+ return opcodeLabel(f.Opcode)
+ case 2:
+ return fmt.Sprintf("%db", len(f.Payload))
+ case 3:
+ return f.Timestamp.Local().Format("15:04:05.000")
+ }
+ return ""
+ }
+ frameTable.CellStyle = func(row, col int) widget.Importance {
+ if col != 0 || row >= len(*filteredFrames) {
+ return widget.MediumImportance
+ }
+ if (*filteredFrames)[row].Direction == events.WebSocketClient {
+ return widget.HighImportance
+ }
+ return widget.SuccessImportance
+ }
+ return frameTable, frameTable.Build()
+}
+
+func buildFrameDetail(win fyne.Window, filteredFrames *[]store.WebSocketFrame, frameTable *widgets.DataTable) (*widgets.TextView, fyne.CanvasObject) {
+ payloadView := widgets.NewTextView()
+ payloadView.SetWindow(win)
+ payloadView.SetPlaceHolder("Select a frame to view payload...")
+
+ frameTable.OnSelect = func(row int) {
+ if row >= len(*filteredFrames) {
+ return
+ }
+ payload := (*filteredFrames)[row].Payload
+ if len(payload) == 0 {
+ payloadView.SetText("(empty)")
+ } else {
+ payloadView.SetText(string(payload))
+ }
+ }
+
+ payloadPane := container.NewBorder(
+ newBoldLabel("Payload"),
+ nil, nil, nil,
+ payloadView.Build(),
+ )
+ return payloadView, payloadPane
+}
+
+func buildWebSocketContent(projectStore *store.Store, win fyne.Window, repeater *repeaterTab) fyne.CanvasObject {
+ var allConns []store.WebSocketConnection
+ var filteredConns []store.WebSocketConnection
+ var allFrames []store.WebSocketFrame
+ var filteredFrames []store.WebSocketFrame
+ var selectedConnID uint64
+
+ connFilterEntry := widget.NewEntry()
+ connFilterEntry.SetPlaceHolder("Filter — host, url...")
+ frameFilterEntry := widget.NewEntry()
+ frameFilterEntry.SetPlaceHolder("Filter frames — payload, type...")
+
+ refilterConns := func() { filteredConns = filterConnections(allConns, connFilterEntry.Text) }
+ refilterFrames := func() { filteredFrames = filterFrames(allFrames, frameFilterEntry.Text) }
+
+ connTable, connTableObj := buildConnTable(win, &filteredConns)
+ frameTable, frameTableObj := buildFrameTable(win, &filteredFrames)
+ payloadView, payloadPane := buildFrameDetail(win, &filteredFrames, frameTable)
+
+ loadFrames := func(connID uint64) {
+ go func() {
+ loaded, err := projectStore.FramesForConnection(connID)
+ if err != nil {
+ logger.Error("ws ui: load frames %d: %v", connID, err)
+ return
+ }
+ fyne.Do(func() {
+ allFrames = loaded
+ refilterFrames()
+ frameTable.Refresh()
+ payloadView.SetText("")
+ })
+ }()
+ }
+
+ connTable.OnSelect = func(row int) {
+ if row >= len(filteredConns) {
+ return
+ }
+ selectedConnID = filteredConns[row].ID
+ loadFrames(selectedConnID)
+ }
+
+ connTable.MenuItems = func(row int) []widgets.ContextMenuItem {
+ if row >= len(filteredConns) {
+ return nil
+ }
+ c := filteredConns[row]
+ return []widgets.ContextMenuItem{
+ {Label: "Send to Repeater", Action: func() {
+ if repeater != nil {
+ repeater.AddWSTab(c.Host, c.URL, c.TLS)
+ }
+ }},
+ {Label: "Copy URL", Action: func() {
+ fyne.CurrentApp().Clipboard().SetContent(c.URL)
+ }},
+ }
+ }
+
+ connFilterEntry.OnChanged = func(q string) {
+ filteredConns = filterConnections(allConns, q)
+ connTable.Refresh()
+ }
+ frameFilterEntry.OnChanged = func(q string) {
+ filteredFrames = filterFrames(allFrames, q)
+ frameTable.Refresh()
+ }
+
+ go func() {
+ for conn := range projectStore.WebSocketConnections {
+ c := conn
+ fyne.Do(func() {
+ for _, existing := range allConns {
+ if existing.ID == c.ID {
+ return
+ }
+ }
+ allConns = append([]store.WebSocketConnection{c}, allConns...)
+ refilterConns()
+ connTable.Refresh()
+ })
+ }
+ }()
+
+ go func() {
+ for frame := range projectStore.WebSocketFrames {
+ f := frame
+ fyne.Do(func() {
+ for i := range allConns {
+ if allConns[i].ID == f.ConnectionID {
+ allConns[i].FrameCount++
+ break
+ }
+ }
+ refilterConns()
+ connTable.Refresh()
+ if f.ConnectionID != selectedConnID {
+ return
+ }
+ go func() {
+ loaded, err := projectStore.FramesForConnection(f.ConnectionID)
+ if err != nil {
+ logger.Error("ws ui: reload frames %d: %v", f.ConnectionID, err)
+ return
+ }
+ fyne.Do(func() {
+ allFrames = loaded
+ refilterFrames()
+ frameTable.Refresh()
+ })
+ }()
+ })
+ }
+ }()
+
+ refreshBtn := widget.NewButtonWithIcon("Refresh", AppIcon("history"), func() {
+ go func() {
+ loaded, err := projectStore.AllWebSocketConnections()
+ if err != nil {
+ logger.Error("ws ui: load connections: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ allConns = loaded
+ refilterConns()
+ connTable.Refresh()
+ if selectedConnID > 0 {
+ loadFrames(selectedConnID)
+ }
+ })
+ }()
+ })
+
+ go func() {
+ loaded, err := projectStore.AllWebSocketConnections()
+ if err != nil {
+ logger.Error("ws ui: initial load: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ allConns = loaded
+ filteredConns = filterConnections(allConns, "")
+ connTable.Refresh()
+ })
+ }()
+
+ connFilterBar := container.NewBorder(nil, nil, nil, refreshBtn, connFilterEntry)
+ frameFilterBar := container.NewBorder(nil, nil, newBoldLabel("Frames"), nil, frameFilterEntry)
+
+ connPane := container.NewBorder(
+ container.NewVBox(newBoldLabel("Connections"), connFilterBar),
+ nil, nil, nil, connTableObj,
+ )
+ framePane := container.NewBorder(frameFilterBar, nil, nil, nil, frameTableObj)
+
+ bottomSplit := container.NewHSplit(framePane, payloadPane)
+ bottomSplit.SetOffset(0.4)
+ mainSplit := container.NewVSplit(connPane, bottomSplit)
+ mainSplit.SetOffset(0.4)
+ return mainSplit
+}
+
+func opcodeLabel(op events.WebSocketOpcode) string {
+ switch op {
+ case events.WebSocketText:
+ return "Text"
+ case events.WebSocketBinary:
+ return "Binary"
+ case events.WebSocketPing:
+ return "Ping"
+ case events.WebSocketPong:
+ return "Pong"
+ case events.WebSocketClose:
+ return "Close"
+ }
+ return fmt.Sprintf("0x%x", int(op))
+}