diff --git a/frontend/e2e/plugin-api-bridge.spec.js b/frontend/e2e/plugin-api-bridge.spec.js index 9173a8b..0bbbdff 100644 --- a/frontend/e2e/plugin-api-bridge.spec.js +++ b/frontend/e2e/plugin-api-bridge.spec.js @@ -95,6 +95,52 @@ test.describe('D: Plugin API bridge', () => { await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available'); }); + test('sync plugin API routes through mocked Wails bridge', async ({ page }) => { + const result = await page.evaluate(async () => { + const api = window.createPluginAPI('verstak.sync'); + const initial = await api.sync.status(); + await api.sync.testConnection('https://sync.example.test', 'alice', 'secret'); + await api.sync.configure('https://sync.example.test', 'alice', 'secret'); + await api.sync.setInterval(15); + const configured = await api.sync.status(); + const syncNow = await api.sync.now(); + await api.sync.disconnect(); + const disconnected = await api.sync.status(); + api.dispose(); + return { initial, configured, syncNow, disconnected }; + }); + + expect(result.initial.statusLabel).toBe('disabled'); + expect(result.configured.configured).toBe(true); + expect(result.configured.serverUrl).toBe('https://sync.example.test'); + expect(result.configured.syncInterval).toBe(15); + expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 }); + expect(result.disconnected.configured).toBe(false); + expect(result.disconnected.statusLabel).toBe('disabled'); + }); + + test('backend plugin events are dispatched to subscribed frontend handlers', async ({ page }) => { + const result = await page.evaluate(async () => { + const api = window.createPluginAPI('verstak.platform-test'); + let received = null; + const unsubscribe = await api.events.subscribe('browser.capture.page', (event) => { + received = event; + }); + window.__VERSTAK_DISPATCH_BACKEND_EVENT__({ + name: 'browser.capture.page', + timestamp: '2026-06-27T00:00:00.000Z', + payload: { url: 'https://example.com/article' } + }); + unsubscribe(); + api.dispose(); + return received; + }); + + expect(result.name).toBe('browser.capture.page'); + expect(result.payload.url).toBe('https://example.com/article'); + expect(result.timestamp).toBe('2026-06-27T00:00:00.000Z'); + }); + test('platform-test command and event handlers are cleaned up after leaving plugin view', async ({ page }) => { await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click(); diff --git a/frontend/e2e/plugin-manager-layout.spec.js b/frontend/e2e/plugin-manager-layout.spec.js index 3ce5c3f..b0e8a08 100644 --- a/frontend/e2e/plugin-manager-layout.spec.js +++ b/frontend/e2e/plugin-manager-layout.spec.js @@ -16,9 +16,10 @@ test.describe('E: Plugin Manager layout', () => { }); test('plugin list scrolls through the global main scroll surface and stays responsive', async ({ page }) => { + const basePluginCount = await page.locator('.plugin-card').count(); await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18)); await page.locator('button.reload-btn').click(); - await expect(page.locator('.plugin-card')).toHaveCount(21, { timeout: 10000 }); + await expect(page.locator('.plugin-card')).toHaveCount(basePluginCount + 18, { timeout: 10000 }); const manager = page.locator('.plugin-manager'); const scrollSurface = page.locator('.content.scroll-surface'); diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index 22ffe30..175fcd7 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -71,6 +71,24 @@ function dispatchLocalEvent(pluginId, eventName, payload) { }); } +function dispatchBackendEvent(event) { + if (!event || !event.name) return; + const handlers = getEventHandlers(event.name).slice(); + handlers.forEach(function(handler) { + try { + handler(event); + } catch (e) { + console.error('[VerstakPluginAPI] backend event handler error:', e); + } + }); +} + +window.__VERSTAK_DISPATCH_BACKEND_EVENT__ = dispatchBackendEvent; + +if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__ && window.runtime && typeof window.runtime.EventsOnMultiple === 'function') { + window.__VERSTAK_BACKEND_EVENT_BRIDGE__ = window.runtime.EventsOnMultiple('verstak:plugin-event', dispatchBackendEvent, -1); +} + function commandKey(pluginId, commandId) { return pluginId + ':' + commandId; } @@ -150,7 +168,9 @@ export function createPluginAPI(pluginId) { await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() { return App.PublishPluginEvent(pluginId, type, payload || {}); }); - dispatchLocalEvent(pluginId, type, payload || {}); + if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__) { + dispatchLocalEvent(pluginId, type, payload || {}); + } }, subscribe: function(type, handler) { assertActive('events.subscribe(' + type + ')'); diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index 2c7f105..b410620 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -133,11 +133,35 @@ }, rootPath: '/tmp/verstak-test/plugins/files', error: '' + }, + 'verstak.sync': { + status: 'loaded', + enabled: true, + manifest: { + schemaVersion: 1, + id: 'verstak.sync', + name: 'Sync', + version: '0.1.0', + apiVersion: '0.1.0', + description: 'Synchronize vault data across devices.', + source: 'official', + icon: 'refresh-cw', + provides: ['verstak/sync/v1', 'verstak/sync.status/v1'], + requires: ['verstak/core/files/v1'], + permissions: ['files.read', 'files.write', 'network.remote', 'storage.namespace', 'sync.participate', 'ui.register'], + frontend: { entry: 'frontend/dist/index.js' }, + contributes: { + settingsPanels: [{ id: 'verstak.sync.settings', title: 'Sync', component: 'SyncSettings' }], + statusBarItems: [{ id: 'verstak.sync.status', label: 'Sync', position: 'right' }] + } + }, + rootPath: '/tmp/verstak-test/plugins/sync', + error: '' } }; var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' }; - var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }] }; + var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.sync'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }] }; var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] }; var workbenchPreferences = {}; var openedResources = []; @@ -149,6 +173,7 @@ window.__wailsMockExternalOpens = []; var workspaceTree = makeDefaultWorkspaceTree(); var reloadResponseMode = 'tuple'; + var syncState = makeDefaultSyncState(); // ── Helpers ──────────────────────────────────────────────────────── function makeDefaultWorkspaceTree() { @@ -197,6 +222,24 @@ }; } + function makeDefaultSyncState() { + return { + configured: false, + serverUrl: '', + deviceId: 'mock-device', + deviceName: '', + connected: false, + revoked: false, + tokenStored: false, + unpushedOps: 0, + lastSyncAt: '', + syncInterval: 0, + lastError: '', + statusLabel: 'disabled', + serverSequence: 0 + }; + } + function normalizeVaultPath(relativePath, allowRoot) { var p = String(relativePath || ''); if (p.indexOf('\x00') !== -1) return { error: 'invalid-path: null-byte' }; @@ -273,6 +316,7 @@ caps.push({ name: 'verstak/core/capability-registry/v1', description: 'Capability registry', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' }); + caps.push({ name: 'verstak/core/sync/v1', description: 'Sync API', pluginId: 'verstak-desktop', status: 'stable' }); for (var id in pluginStates) { var s = pluginStates[id]; if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) { @@ -296,10 +340,39 @@ { name: 'files.write', description: 'Write vault files', dangerous: true }, { name: 'files.delete', description: 'Trash vault files', dangerous: true }, { name: 'files.openExternal', description: 'Open vault files and folders externally', dangerous: true }, - { name: 'workbench.open', description: 'Request Workbench open/edit routing', dangerous: false } + { name: 'workbench.open', description: 'Request Workbench open/edit routing', dangerous: false }, + { name: 'network.remote', description: 'Connect to remote network services', dangerous: true }, + { name: 'sync.participate', description: 'Participate in vault sync', dangerous: true } ]; } + function syncStatusDTO() { + return { + configured: syncState.configured, + serverUrl: syncState.serverUrl, + deviceId: syncState.deviceId, + deviceName: syncState.deviceName, + connected: syncState.connected, + revoked: syncState.revoked, + tokenStored: syncState.tokenStored, + unpushedOps: syncState.unpushedOps, + lastSyncAt: syncState.lastSyncAt, + syncInterval: syncState.syncInterval, + lastError: syncState.lastError, + statusLabel: syncState.statusLabel + }; + } + + function requirePluginSyncPermission(pluginId, remote) { + var err = requirePluginPermission(pluginId, 'sync.participate'); + if (err) return err; + if (remote) { + err = requirePluginPermission(pluginId, 'network.remote'); + if (err) return err; + } + return ''; + } + function allContributions() { var views = [], commands = [], sidebarItems = [], statusBarItems = [], settingsPanels = [], openProviders = [], workspaceItems = []; for (var id in pluginStates) { @@ -998,6 +1071,60 @@ workbenchPreferences = Object.assign({}, workbenchPreferences, preferences || {}); return Promise.resolve(''); }, + PluginSyncStatus: function (pluginId) { + var err = requirePluginSyncPermission(pluginId, false); + if (err) return Promise.resolve([{}, err]); + return Promise.resolve([syncStatusDTO(), '']); + }, + PluginSyncConfigure: function (pluginId, serverUrl) { + var err = requirePluginSyncPermission(pluginId, true); + if (err) return Promise.resolve(err); + syncState.configured = true; + syncState.serverUrl = serverUrl || ''; + syncState.deviceId = 'mock-device'; + syncState.deviceName = 'mock-device'; + syncState.connected = true; + syncState.revoked = false; + syncState.tokenStored = true; + syncState.lastError = ''; + syncState.statusLabel = 'connected'; + pluginSettings[pluginId] = Object.assign({}, pluginSettings[pluginId] || {}, { + serverUrl: syncState.serverUrl, + syncStatus: syncState.statusLabel + }); + return Promise.resolve(''); + }, + PluginSyncDisconnect: function (pluginId) { + var err = requirePluginSyncPermission(pluginId, false); + if (err) return Promise.resolve(err); + syncState = makeDefaultSyncState(); + pluginSettings[pluginId] = Object.assign({}, pluginSettings[pluginId] || {}, { + serverUrl: '', + syncStatus: syncState.statusLabel + }); + return Promise.resolve(''); + }, + PluginSyncTestConnection: function (pluginId, serverUrl) { + var err = requirePluginSyncPermission(pluginId, true); + if (err) return Promise.resolve(err); + if (!serverUrl) return Promise.resolve('server URL is required'); + return Promise.resolve(''); + }, + PluginSyncSetInterval: function (pluginId, minutes) { + var err = requirePluginSyncPermission(pluginId, false); + if (err) return Promise.resolve(err); + syncState.syncInterval = Number(minutes) || 0; + return Promise.resolve(''); + }, + PluginSyncNow: function (pluginId) { + var err = requirePluginSyncPermission(pluginId, true); + if (err) return Promise.resolve([{}, err]); + if (!syncState.configured) return Promise.resolve([{}, 'sync not configured']); + syncState.lastSyncAt = new Date().toISOString(); + syncState.lastError = ''; + syncState.statusLabel = 'connected'; + return Promise.resolve([{ pushed: 0, pulled: 0, serverSequence: syncState.serverSequence }, '']); + }, GetPluginAssetContent: function (pluginId, assetPath) { if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') { return Promise.resolve(platformTestBundle()); @@ -1415,10 +1542,34 @@ }, rootPath: '/tmp/verstak-test/plugins/files', error: '' + }, + 'verstak.sync': { + status: 'loaded', + enabled: true, + manifest: { + schemaVersion: 1, + id: 'verstak.sync', + name: 'Sync', + version: '0.1.0', + apiVersion: '0.1.0', + description: 'Synchronize vault data across devices.', + source: 'official', + icon: 'refresh-cw', + provides: ['verstak/sync/v1', 'verstak/sync.status/v1'], + requires: ['verstak/core/files/v1'], + permissions: ['files.read', 'files.write', 'network.remote', 'storage.namespace', 'sync.participate', 'ui.register'], + frontend: { entry: 'frontend/dist/index.js' }, + contributes: { + settingsPanels: [{ id: 'verstak.sync.settings', title: 'Sync', component: 'SyncSettings' }], + statusBarItems: [{ id: 'verstak.sync.status', label: 'Sync', position: 'right' }] + } + }, + rootPath: '/tmp/verstak-test/plugins/sync', + error: '' } }; vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' }; - vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }] }; + vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.sync'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }] }; appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] }; workbenchPreferences = {}; openedResources = []; @@ -1428,6 +1579,7 @@ window.__wailsMockExternalOpens = []; workspaceTree = makeDefaultWorkspaceTree(); reloadResponseMode = 'tuple'; + syncState = makeDefaultSyncState(); }, setPluginStatus: function (pluginId, status, enabled) { if (pluginStates[pluginId]) { diff --git a/internal/api/app.go b/internal/api/app.go index 1417f7f..5d26352 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -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() diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 1795590..80248b2 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -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"]) + } +} diff --git a/internal/core/browserreceiver/receiver.go b/internal/core/browserreceiver/receiver.go new file mode 100644 index 0000000..38443ba --- /dev/null +++ b/internal/core/browserreceiver/receiver.go @@ -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}) +} diff --git a/internal/core/browserreceiver/receiver_test.go b/internal/core/browserreceiver/receiver_test.go new file mode 100644 index 0000000..dae7383 --- /dev/null +++ b/internal/core/browserreceiver/receiver_test.go @@ -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()) + } +} diff --git a/internal/core/events/bus.go b/internal/core/events/bus.go index 708ec94..78f08bc 100644 --- a/internal/core/events/bus.go +++ b/internal/core/events/bus.go @@ -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) { diff --git a/internal/core/sync/service.go b/internal/core/sync/service.go index e13c93b..3fc87b0 100644 --- a/internal/core/sync/service.go +++ b/internal/core/sync/service.go @@ -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() diff --git a/internal/shell/debug/logger.go b/internal/shell/debug/logger.go index 88e95e7..13a8c70 100644 --- a/internal/shell/debug/logger.go +++ b/internal/shell/debug/logger.go @@ -13,8 +13,8 @@ import ( ) var ( - logger *log.Logger - mu sync.Mutex + logger *log.Logger + mu sync.Mutex enabled bool ) diff --git a/main.go b/main.go index caf2336..4f6e05a 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "github.com/verstak/verstak-desktop/internal/api" "github.com/verstak/verstak-desktop/internal/core/appsettings" + "github.com/verstak/verstak-desktop/internal/core/browserreceiver" "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" @@ -251,9 +252,17 @@ func main() { syncService = syncsvc.NewService(vaultService.GetVaultPath(), "") } app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled) + browserReceiver := browserreceiver.New(eventBus) + browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver) + if err != nil { + log.Printf("[browserreceiver] local receiver disabled: %v", err) + } else { + defer browserReceiverServer.Close() + log.Printf("[browserreceiver] local receiver listening at %s", browserReceiverServer.URL()) + } // ─── Wails App ─────────────────────────────────────────── - err := wails.Run(&options.App{ + err = wails.Run(&options.App{ Title: "Verstak", Width: 1200, Height: 800, diff --git a/scripts/check.sh b/scripts/check.sh index 9a33e88..b36fe8b 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -41,7 +41,7 @@ report "go mod download" $? report "go vet" $? # ── Go fmt (non-destructive — only report unformatted files) ── -UNFORMATTED=$(cd "$ROOT" && gofmt -l . 2>/dev/null || go fmt -n ./... 2>&1 || true) +UNFORMATTED=$(cd "$ROOT" && find . -path ./vendor -prune -o -name '*.go' -print | xargs gofmt -l 2>/dev/null || go fmt -n ./... 2>&1 || true) if [ -z "$UNFORMATTED" ]; then echo " ✅ gofmt: all files formatted" else @@ -58,15 +58,17 @@ report "go mod tidy" $? echo "[frontend]" if ensure_npm_deps "$ROOT/frontend"; then if grep -q '"lint"' "$ROOT/frontend/package.json" 2>/dev/null; then - (cd "$ROOT/frontend" && npm run lint 2>&1 || true) - report "frontend lint" $? + FRONTEND_LINT_STATUS=0 + (cd "$ROOT/frontend" && npm run lint 2>&1) || FRONTEND_LINT_STATUS=$? + report "frontend lint" "$FRONTEND_LINT_STATUS" else echo " ℹ️ no lint script in frontend/package.json" fi # Always run tsc --noEmit if typescript is available if [ -f "$ROOT/frontend/node_modules/.bin/tsc" ]; then - (cd "$ROOT/frontend" && npx tsc --noEmit 2>&1 || true) - report "frontend tsc --noEmit" $? + FRONTEND_TSC_STATUS=0 + (cd "$ROOT/frontend" && npx tsc --noEmit 2>&1) || FRONTEND_TSC_STATUS=$? + report "frontend tsc --noEmit" "$FRONTEND_TSC_STATUS" fi else echo " ℹ️ no frontend/package.json" diff --git a/scripts/test.sh b/scripts/test.sh index 905af6d..3853a22 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -34,16 +34,18 @@ echo "=== verstak-desktop test ===" # ── Go tests ── (cd "$ROOT" && go mod download) -OUTPUT=$(cd "$ROOT" && go test -count=1 -v ./... 2>&1) || true +GO_TEST_STATUS=0 +OUTPUT=$(cd "$ROOT" && go test -count=1 -v ./... 2>&1) || GO_TEST_STATUS=$? echo "$OUTPUT" | grep -E '(FAIL|PASS|---)' || true -report "go test" $? +report "go test" "$GO_TEST_STATUS" # ── Frontend tests ── echo "[frontend]" if ensure_npm_deps "$ROOT/frontend"; then if grep -q '"test"' "$ROOT/frontend/package.json" 2>/dev/null; then - (cd "$ROOT/frontend" && npm test 2>&1 || true) - report "frontend test" $? + FRONTEND_TEST_STATUS=0 + (cd "$ROOT/frontend" && npm test 2>&1) || FRONTEND_TEST_STATUS=$? + report "frontend test" "$FRONTEND_TEST_STATUS" else echo " ℹ️ no test script in frontend/package.json" fi