Add browser inbox receiver

This commit is contained in:
2026-06-27 18:39:01 +08:00
parent fb68c54409
commit a2791c494f
14 changed files with 734 additions and 32 deletions
+209
View File
@@ -0,0 +1,209 @@
// Package browserreceiver hosts the local HTTP protocol used by the browser extension.
package browserreceiver
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/verstak/verstak-desktop/internal/core/events"
)
const capturePath = "/api/browser-inbox/v1/captures"
const DefaultAddr = "127.0.0.1:47731"
type Receiver struct {
bus *events.Bus
}
type Server struct {
listener net.Listener
server *http.Server
}
type CapturePayload struct {
SchemaVersion int `json:"schemaVersion"`
CaptureID string `json:"captureId"`
CapturedAt string `json:"capturedAt"`
Source string `json:"source"`
Kind string `json:"kind"`
Page CapturePage `json:"page"`
Selection *CaptureSelection `json:"selection,omitempty"`
Link *CaptureLink `json:"link,omitempty"`
Browser *CaptureBrowser `json:"browser,omitempty"`
Context interface{} `json:"context,omitempty"`
}
type CapturePage struct {
URL string `json:"url"`
Title string `json:"title"`
Domain string `json:"domain"`
}
type CaptureSelection struct {
Text string `json:"text"`
}
type CaptureLink struct {
URL string `json:"url"`
Text string `json:"text"`
}
type CaptureBrowser struct {
Name string `json:"name"`
}
func New(bus *events.Bus) *Receiver {
return &Receiver{bus: bus}
}
func Start(addr string, receiver *Receiver) (*Server, error) {
if receiver == nil {
return nil, fmt.Errorf("receiver is required")
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{
listener: listener,
server: &http.Server{
Handler: receiver,
},
}
go func() {
if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[browserreceiver] serve: %v", err)
}
}()
return s, nil
}
func (s *Server) URL() string {
if s == nil || s.listener == nil {
return ""
}
return "http://" + s.listener.Addr().String()
}
func (s *Server) Close() error {
if s == nil || s.server == nil {
return nil
}
return s.server.Shutdown(context.Background())
}
func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
if req.URL.Path != capturePath {
http.NotFound(w, req)
return
}
if req.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
return
}
var payload CapturePayload
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := payload.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
eventName := "browser.capture." + payload.Kind
if r.bus == nil || !r.bus.HasSubscribers(eventName) {
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
r.bus.Publish(events.Event{
Name: eventName,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: payload.EventPayload(),
})
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "accepted",
"captureId": payload.CaptureID,
})
}
func (p CapturePayload) Validate() error {
if p.SchemaVersion != 1 {
return fmt.Errorf("unsupported schemaVersion")
}
if strings.TrimSpace(p.CaptureID) == "" {
return fmt.Errorf("captureId is required")
}
if strings.TrimSpace(p.CapturedAt) == "" {
return fmt.Errorf("capturedAt is required")
}
if p.Kind != "page" && p.Kind != "selection" && p.Kind != "link" {
return fmt.Errorf("unsupported kind")
}
if strings.TrimSpace(p.Page.URL) == "" {
return fmt.Errorf("page.url is required")
}
if p.Kind == "selection" && (p.Selection == nil || strings.TrimSpace(p.Selection.Text) == "") {
return fmt.Errorf("selection.text is required")
}
if p.Kind == "link" && (p.Link == nil || strings.TrimSpace(p.Link.URL) == "") {
return fmt.Errorf("link.url is required")
}
return nil
}
func (p CapturePayload) EventPayload() map[string]interface{} {
pageURL := strings.TrimSpace(p.Page.URL)
result := map[string]interface{}{
"captureId": strings.TrimSpace(p.CaptureID),
"capturedAt": strings.TrimSpace(p.CapturedAt),
"source": strings.TrimSpace(p.Source),
"kind": p.Kind,
"url": pageURL,
"title": strings.TrimSpace(p.Page.Title),
"domain": captureDomain(pageURL, p.Page.Domain),
}
if p.Browser != nil {
result["browserName"] = strings.TrimSpace(p.Browser.Name)
}
if p.Context != nil {
result["context"] = p.Context
}
switch p.Kind {
case "selection":
result["text"] = strings.TrimSpace(p.Selection.Text)
case "link":
linkURL := strings.TrimSpace(p.Link.URL)
result["url"] = linkURL
result["title"] = strings.TrimSpace(p.Link.Text)
result["domain"] = captureDomain(linkURL, "")
}
return result
}
func captureDomain(rawURL, fallback string) string {
if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.Hostname() != "" {
return u.Hostname()
}
return strings.TrimSpace(fallback)
}
func writeError(w http.ResponseWriter, status int, message string) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
@@ -0,0 +1,172 @@
package browserreceiver
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/verstak/verstak-desktop/internal/core/events"
)
func TestReceiverAcceptsSelectionCaptureAndPublishesEvent(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.selection", func(event events.Event) {
received <- event
})
receiver := New(bus)
body := `{
"schemaVersion": 1,
"captureId": "capture-123",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "selection",
"page": {
"url": "https://example.com/article",
"title": "Example Article",
"domain": "example.com"
},
"selection": {
"text": "selected text"
},
"browser": {
"name": "Chromium"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
var response map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("response json: %v", err)
}
if response["status"] != "accepted" {
t.Fatalf("response status = %q, want accepted", response["status"])
}
if response["captureId"] != "capture-123" {
t.Fatalf("response captureId = %q, want capture-123", response["captureId"])
}
event := <-received
if event.Name != "browser.capture.selection" {
t.Fatalf("event name = %q, want browser.capture.selection", event.Name)
}
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["captureId"] != "capture-123" {
t.Fatalf("payload captureId = %v, want capture-123", payload["captureId"])
}
if payload["url"] != "https://example.com/article" {
t.Fatalf("payload url = %v, want https://example.com/article", payload["url"])
}
if payload["title"] != "Example Article" {
t.Fatalf("payload title = %v, want Example Article", payload["title"])
}
if payload["text"] != "selected text" {
t.Fatalf("payload text = %v, want selected text", payload["text"])
}
if payload["capturedAt"] != "2026-06-27T00:00:00.000Z" {
t.Fatalf("payload capturedAt = %v, want documented timestamp", payload["capturedAt"])
}
if payload["domain"] != "example.com" {
t.Fatalf("payload domain = %v, want example.com", payload["domain"])
}
}
func TestServerStartsOnLocalAddressAndAcceptsCapture(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {})
receiver := New(bus)
server, err := Start("127.0.0.1:0", receiver)
if err != nil {
t.Fatalf("Start: %v", err)
}
defer server.Close()
response, err := http.Post(server.URL()+capturePath, "application/json", bytes.NewBufferString(`{
"schemaVersion": 1,
"captureId": "capture-server",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`))
if err != nil {
t.Fatalf("post capture: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusAccepted {
body, _ := io.ReadAll(response.Body)
t.Fatalf("status = %d, want %d; body=%s", response.StatusCode, http.StatusAccepted, string(body))
}
}
func TestReceiverRejectsCaptureWhenNoConsumerIsRegistered(t *testing.T) {
receiver := New(events.NewBus())
body := `{
"schemaVersion": 1,
"captureId": "capture-queued",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusServiceUnavailable, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("browser inbox unavailable")) {
t.Fatalf("response body = %q, want unavailable error", rec.Body.String())
}
}
func TestReceiverRejectsInvalidCapturePayload(t *testing.T) {
receiver := New(events.NewBus())
body := `{
"schemaVersion": 1,
"captureId": "capture-123",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "link",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("link.url is required")) {
t.Fatalf("response body = %q, want validation error", rec.Body.String())
}
}
+7
View File
@@ -35,6 +35,13 @@ func (b *Bus) Subscribe(event string, handler Handler) {
b.handlers[event] = append(b.handlers[event], handler)
}
// HasSubscribers reports whether an event has at least one registered handler.
func (b *Bus) HasSubscribers(event string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.handlers[event]) > 0
}
// Unsubscribe removes all handlers for a plugin (matched by prefix or exact).
// For now, a simple version: clear all handlers for a given event name.
func (b *Bus) Unsubscribe(event string) {
+7 -7
View File
@@ -97,14 +97,14 @@ func (s *Service) RecordOp(entityType, entityID, opType string, payload interfac
}
op := Op{
ID: id,
OpID: id,
DeviceID: s.deviceID,
EntityType: entityType,
EntityID: entityID,
OpType: opType,
ID: id,
OpID: id,
DeviceID: s.deviceID,
EntityType: entityType,
EntityID: entityID,
OpType: opType,
PayloadJSON: payloadStr,
CreatedAt: now,
CreatedAt: now,
}
ops, err := s.loadOps()