package webkit import ( "context" "encoding/json" "errors" "fmt" "sync" "github.com/bnema/dumber/internal/application/port" "github.com/bnema/dumber/internal/logging" "github.com/bnema/puregotk/v4/javascriptcore" "github.com/bnema/puregotk/v4/webkit" "github.com/rs/zerolog" ) // Message is the JS -> Go message envelope sent via postMessage. type Message = port.WebUIMessage // MessageHandler is an alias for port.WebUIMessageHandler. // Kept for backward compatibility within the webkit package. type MessageHandler = port.WebUIMessageHandler // MessageHandlerFunc is an alias for port.WebUIMessageHandlerFunc. // Kept for backward compatibility within the webkit package. type MessageHandlerFunc = port.WebUIMessageHandlerFunc type handlerEntry struct { handler MessageHandler callback string errorCallback string world string } // Compile-time check that MessageRouter implements port.WebUIHandlerRouter. var _ port.WebUIHandlerRouter = (*MessageRouter)(nil) // MessageRouter dispatches script-message events to registered handlers. type MessageRouter struct { handlers map[string]handlerEntry baseCtx context.Context mu sync.RWMutex callbacks []any signals []uint idMu sync.Mutex syncedIDs map[WebViewID]bool } // NewMessageRouter creates a new message router. func NewMessageRouter(ctx context.Context) *MessageRouter { if ctx == nil { ctx = context.Background() } return &MessageRouter{ handlers: make(map[string]handlerEntry), baseCtx: ctx, syncedIDs: make(map[WebViewID]bool), } } // SetBaseContext updates the base context used for logging and handler execution. func (r *MessageRouter) SetBaseContext(ctx context.Context) { if r == nil { return } if ctx == nil { ctx = context.Background() } r.mu.Lock() r.baseCtx = ctx r.mu.Unlock() } func (r *MessageRouter) baseContext() context.Context { if r == nil { return context.Background() } r.mu.RLock() ctx := r.baseCtx r.mu.RUnlock() if ctx == nil { return context.Background() } return ctx } // RegisterHandler registers a handler for a message type. func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error { if msgType == "" { return errors.New("message type cannot be empty") } if handler == nil { return errors.New("message handler cannot be nil") } r.mu.Lock() defer r.mu.Unlock() r.handlers[msgType] = handlerEntry{handler: handler} return nil } // RegisterHandlerWithCallbacks registers a handler and response callbacks. // callback is invoked on success, errorCallback (optional) on failure. // worldName allows targeting a specific script world (empty for main world). func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler MessageHandler) error { if msgType == "" { return errors.New("message type cannot be empty") } if handler == nil { return errors.New("message handler cannot be nil") } if callback == "" { return errors.New("callback cannot be empty when registering with callbacks") } r.mu.Lock() defer r.mu.Unlock() r.handlers[msgType] = handlerEntry{ handler: handler, callback: callback, errorCallback: errorCallback, world: worldName, } return nil } // SetupMessageHandler wires the router into the given UserContentManager. // It registers the script message handler in the MAIN world (not isolated). // WebKit's messageHandlers is only available in main world. // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler. func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) { baseCtx := r.baseContext() log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger() log.Debug().Msg("SetupMessageHandler called") if ucm == nil { log.Warn().Msg("SetupMessageHandler: ucm is nil") return 0, errors.New("user content manager is nil") } log.Debug().Msg("SetupMessageHandler: connecting signal with detail") // Connect to signal BEFORE registering handler to avoid race conditions // (as recommended by WebKit documentation) cb := func(ucm webkit.UserContentManager, valuePtr uintptr) { log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked") r.handleScriptMessage(ucm, valuePtr) } r.mu.Lock() r.callbacks = append(r.callbacks, cb) // keep callback alive r.mu.Unlock() // Connect to "script-message-received::dumber" with handler name as signal detail signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb) log.Debug().Uint64("signal_id", uint64(signalID)).Str("handler", MessageHandlerName).Msg("signal connected with detail") r.mu.Lock() r.signals = append(r.signals, signalID) r.mu.Unlock() log.Debug().Msg("SetupMessageHandler: registering handler in main world") // Register handler in main world - webkit.messageHandlers is only available there // nil = main/default world (NULL in C), empty string "" would be a world named "" if ok := ucm.RegisterScriptMessageHandler(MessageHandlerName, nil); !ok { log.Warn().Str("handler", MessageHandlerName).Msg("RegisterScriptMessageHandler returned false") return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName) } log.Info(). Str("handler", MessageHandlerName). Str("world", "main"). Uint64("signal_id", uint64(signalID)). Msg("script message handler connected") return signalID, nil } // handleScriptMessage decodes the JSC value and routes it to the correct handler. func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) { baseCtx := r.baseContext() log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger() log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry") msg, ok := decodeScriptMessage(valuePtr, log) if !ok { return } senderWV := lookupSenderWebView(senderUCM) if senderWV == nil { log.Warn().Msg("rejecting script message from unknown sender") return } if r.handleAllowlistedBridgeMessage(senderWV, msg) { return } if !isTrustedBridgeWebView(senderWV) { log.Warn(). Str("type", msg.Type). Uint64("sender_webview_id", uint64(senderWV.ID())). Str("uri", senderWV.URI()). Msg("rejecting script message from untrusted page") return } // Trust the sender WebView resolved from WebKit's UserContentManager, not a // caller-provided webViewId field in page-controlled JSON. msg.WebViewID = uint64(senderWV.ID()) r.syncWebViewID(senderWV) if msg.Type == "" { log.Warn().Msg("script message missing type") return } entry, ok := r.getHandler(msg.Type) if !ok || entry.handler == nil { handlerTypes := r.registeredHandlerTypes() log.Warn().Str("type", msg.Type).Int("registered_handlers", len(handlerTypes)).Msg("no handler registered for message type") for _, handlerType := range handlerTypes { log.Debug().Str("registered_type", handlerType).Msg("available handler") } return } log.Info(). Str("type", msg.Type). Uint64("webview_id", msg.WebViewID). Int("payload_len", len(msg.Payload)). Str("callback", entry.callback). Msg("received script message, dispatching to handler") resp, err := entry.handler.Handle(baseCtx, WebViewID(msg.WebViewID), msg.Payload) log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed") if err != nil { log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") if entry.errorCallback != "" { if respErr := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil { log.Warn().Err(respErr).Msg("failed to dispatch error callback") } } return } if entry.callback != "" { if err := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil { log.Warn().Err(err). Str("callback", entry.callback). Uint64("webview_id", msg.WebViewID). Msg("failed to dispatch callback response") } } } func decodeScriptMessage(valuePtr uintptr, log zerolog.Logger) (Message, bool) { if valuePtr == 0 { log.Warn().Msg("received script message with nil value pointer") return Message{}, false } jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr) if jscValue == nil { log.Warn().Msg("failed to wrap script message JSC value") return Message{}, false } rawJSON := jscValue.ToJson(0) log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result") if rawJSON == "" { log.Error(). Msg("script message JSON serialization failed (ToJson returned empty) - " + "this usually means JS sent non-serializable values like Proxy objects") return Message{}, false } var msg Message if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil { log.Warn().Err(err).Int("json_len", len(rawJSON)).Msg("failed to unmarshal script message") return Message{}, false } return msg, true } // handleAllowlistedBridgeMessage handles the one narrow page-origin hint that // is allowed before trusted-page routing: editable focus state for Vim mode. // This is intentionally not a privileged bridge command; the only effect is // updating editable-focus state on the sender WebView so Vim mode can block or // exit locally. The per-WebView token is a reuse/staleness guard, not a broad // authorization boundary for page privileges. func (r *MessageRouter) handleAllowlistedBridgeMessage(senderWV *WebView, msg Message) bool { if msg.Type != "editable_focus_changed" { return false } log := logging.FromContext(r.baseContext()) if senderWV == nil { log.Warn().Str("type", msg.Type).Msg("dropping allowlisted bridge message without sender WebView") return true } var payload struct { Editable bool `json:"editable"` Token string `json:"token"` } if len(msg.Payload) != 0 { if err := json.Unmarshal(msg.Payload, &payload); err != nil { log.Warn().Err(err).Str("type", msg.Type).Msg("failed to decode allowlisted bridge payload") return true } } if payload.Token == "" || !senderWV.matchesEditableFocusBridgeToken(payload.Token) { log.Warn().Str("type", msg.Type).Msg("rejecting allowlisted bridge message with invalid token") return true } senderWV.dispatchEditableFocusChanged(payload.Editable) return true } func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView { ptr := senderUCM.GoPointer() if ptr == 0 { return nil } return LookupWebViewByUCMPointer(ptr) } func isTrustedBridgeWebView(wv *WebView) bool { if wv == nil { return false } return isTrustedBridgeURI(wv.URI()) } func isTrustedBridgeURI(raw string) bool { return isTrustedSystemviewURL(raw) } func (r *MessageRouter) syncWebViewID(wv *WebView) { if wv == nil { return } if !r.markWebViewIDSynced(wv.ID()) { return } script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID())) wv.RunJavaScriptInWorld(r.baseContext(), script, "") } func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool { r.idMu.Lock() defer r.idMu.Unlock() if r.syncedIDs[id] { return false } r.syncedIDs[id] = true return true } // getHandler retrieves a handler by message type. func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) { r.mu.RLock() defer r.mu.RUnlock() entry, ok := r.handlers[msgType] return entry, ok } func (r *MessageRouter) registeredHandlerTypes() []string { r.mu.RLock() defer r.mu.RUnlock() types := make([]string, 0, len(r.handlers)) for handlerType := range r.handlers { types = append(types, handlerType) } return types } // dispatchResponse serializes the payload and invokes a window callback in JS. func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error { if ctx == nil { ctx = r.baseContext() } if callback == "" { return errors.New("callback name is empty") } if webviewID == 0 { return errors.New("webview id is required for JS callback dispatch") } wv := LookupWebView(webviewID) if wv == nil { return fmt.Errorf("webview %d not found", webviewID) } data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal callback payload: %w", err) } script := fmt.Sprintf( `(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}`+ `else{console.warn("dumber callback missing: %[1]s");}}`+ `catch(e){console.error("dumber callback %[1]s failed", e);}})();`, callback, string(data), ) wv.RunJavaScriptInWorld(ctx, script, world) return nil }