gui/pkg/display/events.go
Snider 723116acb7
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Harden GUI security boundaries
2026-04-15 19:20:58 +01:00

502 lines
13 KiB
Go

package display
import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
core "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/events"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/gorilla/websocket"
)
// EventType represents the type of event.
type EventType string
const (
EventWindowFocus EventType = "window.focus"
EventWindowBlur EventType = "window.blur"
EventWindowMove EventType = "window.move"
EventWindowResize EventType = "window.resize"
EventWindowClose EventType = "window.close"
EventWindowCreate EventType = "window.create"
EventThemeChange EventType = "theme.change"
EventScreenChange EventType = "screen.change"
EventNotificationClick EventType = "notification.click"
EventTrayClick EventType = "tray.click"
EventTrayMenuItemClick EventType = "tray.menuitem.click"
EventKeybindingTriggered EventType = "keybinding.triggered"
EventWindowFileDrop EventType = "window.filedrop"
EventDockVisibility EventType = "dock.visibility-changed"
EventAppStarted EventType = "app.started"
EventAppOpenedWithFile EventType = "app.opened-with-file"
EventAppWillTerminate EventType = "app.will-terminate"
EventAppActive EventType = "app.active"
EventAppInactive EventType = "app.inactive"
EventSystemPowerChange EventType = "system.power-change"
EventSystemSuspend EventType = "system.suspend"
EventSystemResume EventType = "system.resume"
EventContextMenuClick EventType = "contextmenu.item-clicked"
EventWebviewConsole EventType = "webview.console"
EventWebviewException EventType = "webview.exception"
EventCustomEvent EventType = "custom.event"
EventDockProgress EventType = "dock.progress"
EventDockBounce EventType = "dock.bounce"
EventNotificationAction EventType = "notification.action"
EventNotificationDismiss EventType = "notification.dismissed"
EventChatConversation EventType = "chat.conversation"
EventChatMessage EventType = "chat.message"
EventChatToken EventType = "chat.token"
EventChatThinkingStart EventType = "chat.thinking.start"
EventChatThinkingAppend EventType = "chat.thinking.append"
EventChatThinkingEnd EventType = "chat.thinking.end"
EventChatToolCall EventType = "chat.tool.call"
EventChatToolResult EventType = "chat.tool.result"
EventChatImageQueued EventType = "chat.image.queued"
)
// Event represents a display event sent to subscribers.
type Event struct {
Type EventType `json:"type"`
Timestamp int64 `json:"timestamp"`
Window string `json:"window,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
// Subscription represents a client subscription to events.
type Subscription struct {
ID string `json:"id"`
EventTypes []EventType `json:"eventTypes"`
}
// WSEventManager manages WebSocket connections and event subscriptions.
type WSEventManager struct {
upgrader websocket.Upgrader
clients map[*websocket.Conn]*clientState
mu sync.RWMutex
nextSubID int
eventBuffer chan Event
}
// clientState tracks a client's subscriptions.
type clientState struct {
subscriptions map[string]*Subscription
writeMu sync.Mutex
mu sync.RWMutex
}
// NewWSEventManager creates a new event manager.
func NewWSEventManager() *WSEventManager {
em := &WSEventManager{
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return trustedWebSocketOrigin(r)
},
ReadBufferSize: 1024,
WriteBufferSize: 1024,
},
clients: make(map[*websocket.Conn]*clientState),
eventBuffer: make(chan Event, 100),
}
// Start event broadcaster
go em.broadcaster()
return em
}
func trustedWebSocketOrigin(r *http.Request) bool {
if r == nil {
return false
}
if r.URL == nil {
return false
}
if path := strings.TrimSpace(r.URL.Path); path != "" && path != "/" && path != "/events" {
return false
}
if !trustedWebSocketHost(r.Host) {
return false
}
if !trustedWSRequestOrigin(r.RemoteAddr) {
return false
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" || strings.EqualFold(origin, "null") {
return true
}
parsed, err := url.Parse(origin)
if err != nil {
return false
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
return trustedWebSocketHost(parsed.Host)
case "file", "wails", "core", "app":
return true
default:
return false
}
}
func trustedWSRequestOrigin(raw string) bool {
if raw == "" {
return false
}
host := raw
if parsed, _, err := net.SplitHostPort(raw); err == nil {
host = parsed
}
host = strings.Trim(host, "[]")
return isLoopbackHost(host)
}
func isLoopbackHost(host string) bool {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return false
}
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func trustedWebSocketHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
name := host
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
name = parsedHost
}
name = strings.Trim(name, "[]")
switch strings.ToLower(name) {
case "localhost", "127.0.0.1", "::1":
return true
case "localhost:80", "localhost:443", "127.0.0.1:80", "127.0.0.1:443", "[::1]:80", "[::1]:443":
return true
default:
return false
}
}
// broadcaster sends events to all subscribed clients.
func (em *WSEventManager) broadcaster() {
for event := range em.eventBuffer {
em.mu.RLock()
for conn, state := range em.clients {
if em.clientSubscribed(state, event.Type) {
go em.sendEvent(conn, event)
}
}
em.mu.RUnlock()
}
}
// clientSubscribed checks if a client is subscribed to an event type.
func (em *WSEventManager) clientSubscribed(state *clientState, eventType EventType) bool {
state.mu.RLock()
defer state.mu.RUnlock()
for _, sub := range state.subscriptions {
for _, et := range sub.EventTypes {
if et == eventType || et == "*" {
return true
}
}
}
return false
}
// sendEvent sends an event to a specific client.
func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) {
em.mu.RLock()
state, exists := em.clients[conn]
em.mu.RUnlock()
if !exists || state == nil {
return
}
marshalResult := core.JSONMarshal(event)
if !marshalResult.OK {
return
}
data, _ := marshalResult.Value.([]byte)
state.writeMu.Lock()
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
err := conn.WriteMessage(websocket.TextMessage, data)
state.writeMu.Unlock()
if err != nil {
em.removeClient(conn)
}
}
// HandleWebSocket handles WebSocket upgrade and connection.
func (em *WSEventManager) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
if !trustedWebSocketOrigin(r) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
conn, err := em.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
em.mu.Lock()
em.clients[conn] = &clientState{
subscriptions: make(map[string]*Subscription),
}
em.mu.Unlock()
conn.SetReadLimit(64 * 1024)
// Handle incoming messages
go em.handleMessages(conn)
}
// handleMessages processes incoming WebSocket messages.
func (em *WSEventManager) handleMessages(conn *websocket.Conn) {
defer em.removeClient(conn)
for {
_, message, err := conn.ReadMessage()
if err != nil {
return
}
var msg struct {
Action string `json:"action"`
ID string `json:"id,omitempty"`
EventTypes []EventType `json:"eventTypes,omitempty"`
}
if unmarshalResult := core.JSONUnmarshal(message, &msg); !unmarshalResult.OK {
em.closeWithPolicyViolation(conn, "invalid websocket message")
return
}
handled := true
switch msg.Action {
case "subscribe":
em.subscribe(conn, msg.ID, msg.EventTypes)
case "unsubscribe":
em.unsubscribe(conn, msg.ID)
case "list":
em.listSubscriptions(conn)
default:
handled = false
}
if !handled {
em.closeWithPolicyViolation(conn, "unknown websocket action")
return
}
}
}
func (em *WSEventManager) closeWithPolicyViolation(conn *websocket.Conn, reason string) {
em.mu.RLock()
state, exists := em.clients[conn]
em.mu.RUnlock()
if !exists || state == nil {
return
}
state.writeMu.Lock()
defer state.writeMu.Unlock()
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, reason), time.Now().Add(2*time.Second))
}
// subscribe adds a subscription for a client.
func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes []EventType) {
em.mu.RLock()
state, exists := em.clients[conn]
em.mu.RUnlock()
if !exists {
return
}
// Generate ID if not provided
if id == "" {
em.mu.Lock()
em.nextSubID++
id = "sub-" + strconv.Itoa(em.nextSubID)
em.mu.Unlock()
}
state.mu.Lock()
state.subscriptions[id] = &Subscription{
ID: id,
EventTypes: eventTypes,
}
state.mu.Unlock()
// Send confirmation
response := map[string]any{
"type": "subscribed",
"id": id,
"eventTypes": eventTypes,
}
if marshalResult := core.JSONMarshal(response); marshalResult.OK {
responseData, _ := marshalResult.Value.([]byte)
em.writeClientMessage(state, conn, responseData)
}
}
// unsubscribe removes a subscription for a client.
func (em *WSEventManager) unsubscribe(conn *websocket.Conn, id string) {
em.mu.RLock()
state, exists := em.clients[conn]
em.mu.RUnlock()
if !exists {
return
}
state.mu.Lock()
delete(state.subscriptions, id)
state.mu.Unlock()
// Send confirmation
response := map[string]any{
"type": "unsubscribed",
"id": id,
}
if marshalResult := core.JSONMarshal(response); marshalResult.OK {
responseData, _ := marshalResult.Value.([]byte)
em.writeClientMessage(state, conn, responseData)
}
}
// listSubscriptions sends a list of active subscriptions to a client.
func (em *WSEventManager) listSubscriptions(conn *websocket.Conn) {
em.mu.RLock()
state, exists := em.clients[conn]
em.mu.RUnlock()
if !exists {
return
}
state.mu.RLock()
subs := make([]*Subscription, 0, len(state.subscriptions))
for _, sub := range state.subscriptions {
subs = append(subs, sub)
}
state.mu.RUnlock()
response := map[string]any{
"type": "subscriptions",
"subscriptions": subs,
}
if marshalResult := core.JSONMarshal(response); marshalResult.OK {
responseData, _ := marshalResult.Value.([]byte)
em.writeClientMessage(state, conn, responseData)
}
}
func (em *WSEventManager) writeClientMessage(state *clientState, conn *websocket.Conn, data []byte) {
state.writeMu.Lock()
defer state.writeMu.Unlock()
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
_ = conn.WriteMessage(websocket.TextMessage, data)
}
// removeClient removes a client and its subscriptions.
func (em *WSEventManager) removeClient(conn *websocket.Conn) {
em.mu.Lock()
delete(em.clients, conn)
em.mu.Unlock()
conn.Close()
}
// Emit sends an event to all subscribed clients.
func (em *WSEventManager) Emit(event Event) {
event.Timestamp = time.Now().UnixMilli()
select {
case em.eventBuffer <- event:
default:
// Buffer full, drop event
}
}
// EmitWindowEvent is a helper to emit window-related events.
func (em *WSEventManager) EmitWindowEvent(eventType EventType, windowName string, data map[string]any) {
em.Emit(Event{
Type: eventType,
Window: windowName,
Data: data,
})
}
// ConnectedClients returns the number of connected WebSocket clients.
func (em *WSEventManager) ConnectedClients() int {
em.mu.RLock()
defer em.mu.RUnlock()
return len(em.clients)
}
// Info returns a snapshot of the live WebSocket event server.
//
// info := display.GetEventManager().Info()
func (em *WSEventManager) Info() events.ServerInfo {
em.mu.RLock()
defer em.mu.RUnlock()
subscriptionCount := 0
for _, state := range em.clients {
state.mu.RLock()
subscriptionCount += len(state.subscriptions)
state.mu.RUnlock()
}
return events.ServerInfo{
ConnectedClients: len(em.clients),
SubscriptionCount: subscriptionCount,
BufferLength: len(em.eventBuffer),
BufferCapacity: cap(em.eventBuffer),
}
}
// Close shuts down the event manager.
func (em *WSEventManager) Close() {
em.mu.Lock()
for conn := range em.clients {
conn.Close()
}
em.clients = make(map[*websocket.Conn]*clientState)
em.mu.Unlock()
close(em.eventBuffer)
}
type windowEventSource interface {
OnWindowEvent(func(event window.WindowEvent))
}
// AttachWindowListeners attaches event listeners to a specific window.
// Use: em.AttachWindowListeners(windowHandle)
func (em *WSEventManager) AttachWindowListeners(pw windowEventSource) {
if pw == nil {
return
}
pw.OnWindowEvent(func(e window.WindowEvent) {
em.EmitWindowEvent(EventType("window."+e.Type), e.Name, e.Data)
})
}