From 0a477fd288d064d265395c88dbd0f261b723804e Mon Sep 17 00:00:00 2001 From: spiperac Date: Mon, 15 Jun 2026 06:42:13 +0200 Subject: Initial release --- internal/ui/websocket.go | 379 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 internal/ui/websocket.go (limited to 'internal/ui/websocket.go') 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)) +} -- cgit v1.2.3