Add browser inbox receiver
This commit is contained in:
+21
-7
@@ -30,6 +30,11 @@ import (
|
||||
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
||||
)
|
||||
|
||||
var newSyncClient = syncsvc.NewClient
|
||||
var emitFrontendEvent = runtime.EventsEmit
|
||||
|
||||
const pluginEventRuntimeName = "verstak:plugin-event"
|
||||
|
||||
// App is the main application struct exposed to the Wails frontend.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
@@ -974,8 +979,8 @@ func (a *App) PublishPluginEvent(pluginID, eventName string, payload map[string]
|
||||
return ""
|
||||
}
|
||||
|
||||
// SubscribePluginEvent validates subscribe permission for a bundled frontend plugin.
|
||||
// Actual bundled event dispatch is handled by the frontend plugin host event bus.
|
||||
// SubscribePluginEvent validates subscribe permission and bridges backend events
|
||||
// into the bundled frontend plugin host.
|
||||
func (a *App) SubscribePluginEvent(pluginID, eventName string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "events.subscribe"); err != nil {
|
||||
return err.Error()
|
||||
@@ -983,6 +988,15 @@ func (a *App) SubscribePluginEvent(pluginID, eventName string) string {
|
||||
if eventName == "" {
|
||||
return "event name is empty"
|
||||
}
|
||||
if a.eventBus != nil {
|
||||
a.eventBus.Subscribe(eventName, func(event events.Event) {
|
||||
emitFrontendEvent(a.ctx, pluginEventRuntimeName, map[string]interface{}{
|
||||
"name": event.Name,
|
||||
"timestamp": event.Timestamp,
|
||||
"payload": event.Payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1513,7 +1527,7 @@ func (a *App) syncStatus() (*SyncStatusDTO, error) {
|
||||
dto.UnpushedOps = len(unpushed)
|
||||
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||
client := newSyncClient(serverURL, "", "", vaultPath)
|
||||
client.DeviceToken = deviceToken
|
||||
if cfg.Sync.DeviceID != "" {
|
||||
client.DeviceID = cfg.Sync.DeviceID
|
||||
@@ -1570,7 +1584,7 @@ func (a *App) syncConfigure(serverURL, username, password string) error {
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||
client := newSyncClient(serverURL, "", "", vaultPath)
|
||||
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pair: %w", err)
|
||||
@@ -1613,7 +1627,7 @@ func (a *App) syncDisconnect() error {
|
||||
cfg := a.appSettings.Get()
|
||||
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", vaultPath)
|
||||
client := newSyncClient(cfg.Sync.ServerURL, "", "", vaultPath)
|
||||
client.DeviceToken = deviceToken
|
||||
_ = client.RevokeCurrent()
|
||||
}
|
||||
@@ -1647,7 +1661,7 @@ func (a *App) syncTestConnection(serverURL, username, password string) error {
|
||||
if vaultPath == "" {
|
||||
vaultPath = "/tmp"
|
||||
}
|
||||
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||
client := newSyncClient(serverURL, "", "", vaultPath)
|
||||
return client.TestAuth(serverURL, username, password)
|
||||
}
|
||||
|
||||
@@ -1703,7 +1717,7 @@ func (a *App) syncNow() (map[string]interface{}, error) {
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
|
||||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, vaultPath)
|
||||
client := newSyncClient(serverURL, apiKey, deviceID, vaultPath)
|
||||
client.DeviceToken = deviceToken
|
||||
|
||||
unpushed, err := a.syncSvc.GetUnpushedOps()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -21,6 +23,20 @@ import (
|
||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||
)
|
||||
|
||||
func newLocalHTTPTestServer(t *testing.T, handler http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen local test server: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewUnstartedServer(handler)
|
||||
server.Listener = listener
|
||||
server.Start()
|
||||
return server
|
||||
}
|
||||
|
||||
// newTestApp creates an App with a mocked plugin list for testing.
|
||||
func newTestApp(tmpRoot string) *App {
|
||||
return &App{
|
||||
@@ -738,7 +754,7 @@ func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
var pushedDeviceID string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer device-token" {
|
||||
http.Error(w, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -1291,3 +1307,55 @@ func TestPluginBridgeCapabilitiesCommandsAndEventsAreChecked(t *testing.T) {
|
||||
t.Fatal("expected command permission/ownership error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribePluginEventRegistersBackendEventBridge(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
emitted := make(chan map[string]interface{}, 1)
|
||||
originalEmit := emitFrontendEvent
|
||||
emitFrontendEvent = func(_ context.Context, eventName string, data ...interface{}) {
|
||||
if eventName != pluginEventRuntimeName {
|
||||
t.Errorf("eventName = %q, want %q", eventName, pluginEventRuntimeName)
|
||||
}
|
||||
if len(data) != 1 {
|
||||
t.Errorf("data length = %d, want 1", len(data))
|
||||
return
|
||||
}
|
||||
payload, ok := data[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("data[0] type = %T, want map[string]interface{}", data[0])
|
||||
return
|
||||
}
|
||||
emitted <- payload
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
emitFrontendEvent = originalEmit
|
||||
})
|
||||
|
||||
if errStr := app.SubscribePluginEvent("bridge.plugin", "browser.capture.page"); errStr != "" {
|
||||
t.Fatalf("SubscribePluginEvent: %s", errStr)
|
||||
}
|
||||
if !app.eventBus.HasSubscribers("browser.capture.page") {
|
||||
t.Fatal("expected backend event bus subscriber")
|
||||
}
|
||||
|
||||
app.eventBus.Publish(events.Event{
|
||||
Name: "browser.capture.page",
|
||||
Timestamp: "2026-06-27T00:00:00.000Z",
|
||||
Payload: map[string]interface{}{"url": "https://example.com"},
|
||||
})
|
||||
|
||||
event := <-emitted
|
||||
if event["name"] != "browser.capture.page" {
|
||||
t.Fatalf("event name = %v, want browser.capture.page", event["name"])
|
||||
}
|
||||
if event["timestamp"] != "2026-06-27T00:00:00.000Z" {
|
||||
t.Fatalf("event timestamp = %v, want documented timestamp", event["timestamp"])
|
||||
}
|
||||
payload, ok := event["payload"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event payload type = %T, want map[string]interface{}", event["payload"])
|
||||
}
|
||||
if payload["url"] != "https://example.com" {
|
||||
t.Fatalf("payload url = %v, want https://example.com", payload["url"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
logger *log.Logger
|
||||
mu sync.Mutex
|
||||
logger *log.Logger
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user