Add browser inbox receiver
This commit is contained in:
parent
fb68c54409
commit
a2791c494f
|
|
@ -95,6 +95,52 @@ test.describe('D: Plugin API bridge', () => {
|
||||||
await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available');
|
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 }) => {
|
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();
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
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.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
|
||||||
await page.locator('button.reload-btn').click();
|
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 manager = page.locator('.plugin-manager');
|
||||||
const scrollSurface = page.locator('.content.scroll-surface');
|
const scrollSurface = page.locator('.content.scroll-surface');
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
function commandKey(pluginId, commandId) {
|
||||||
return pluginId + ':' + commandId;
|
return pluginId + ':' + commandId;
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +168,9 @@ export function createPluginAPI(pluginId) {
|
||||||
await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() {
|
await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() {
|
||||||
return App.PublishPluginEvent(pluginId, type, payload || {});
|
return App.PublishPluginEvent(pluginId, type, payload || {});
|
||||||
});
|
});
|
||||||
|
if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__) {
|
||||||
dispatchLocalEvent(pluginId, type, payload || {});
|
dispatchLocalEvent(pluginId, type, payload || {});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
subscribe: function(type, handler) {
|
subscribe: function(type, handler) {
|
||||||
assertActive('events.subscribe(' + type + ')');
|
assertActive('events.subscribe(' + type + ')');
|
||||||
|
|
|
||||||
|
|
@ -133,11 +133,35 @@
|
||||||
},
|
},
|
||||||
rootPath: '/tmp/verstak-test/plugins/files',
|
rootPath: '/tmp/verstak-test/plugins/files',
|
||||||
error: ''
|
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 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 appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
||||||
var workbenchPreferences = {};
|
var workbenchPreferences = {};
|
||||||
var openedResources = [];
|
var openedResources = [];
|
||||||
|
|
@ -149,6 +173,7 @@
|
||||||
window.__wailsMockExternalOpens = [];
|
window.__wailsMockExternalOpens = [];
|
||||||
var workspaceTree = makeDefaultWorkspaceTree();
|
var workspaceTree = makeDefaultWorkspaceTree();
|
||||||
var reloadResponseMode = 'tuple';
|
var reloadResponseMode = 'tuple';
|
||||||
|
var syncState = makeDefaultSyncState();
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
function makeDefaultWorkspaceTree() {
|
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) {
|
function normalizeVaultPath(relativePath, allowRoot) {
|
||||||
var p = String(relativePath || '');
|
var p = String(relativePath || '');
|
||||||
if (p.indexOf('\x00') !== -1) return { error: 'invalid-path: null-byte' };
|
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/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/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/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) {
|
for (var id in pluginStates) {
|
||||||
var s = pluginStates[id];
|
var s = pluginStates[id];
|
||||||
if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) {
|
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.write', description: 'Write vault files', dangerous: true },
|
||||||
{ name: 'files.delete', description: 'Trash 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: '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() {
|
function allContributions() {
|
||||||
var views = [], commands = [], sidebarItems = [], statusBarItems = [], settingsPanels = [], openProviders = [], workspaceItems = [];
|
var views = [], commands = [], sidebarItems = [], statusBarItems = [], settingsPanels = [], openProviders = [], workspaceItems = [];
|
||||||
for (var id in pluginStates) {
|
for (var id in pluginStates) {
|
||||||
|
|
@ -998,6 +1071,60 @@
|
||||||
workbenchPreferences = Object.assign({}, workbenchPreferences, preferences || {});
|
workbenchPreferences = Object.assign({}, workbenchPreferences, preferences || {});
|
||||||
return Promise.resolve('');
|
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) {
|
GetPluginAssetContent: function (pluginId, assetPath) {
|
||||||
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
|
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
|
||||||
return Promise.resolve(platformTestBundle());
|
return Promise.resolve(platformTestBundle());
|
||||||
|
|
@ -1415,10 +1542,34 @@
|
||||||
},
|
},
|
||||||
rootPath: '/tmp/verstak-test/plugins/files',
|
rootPath: '/tmp/verstak-test/plugins/files',
|
||||||
error: ''
|
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' };
|
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: [] };
|
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
||||||
workbenchPreferences = {};
|
workbenchPreferences = {};
|
||||||
openedResources = [];
|
openedResources = [];
|
||||||
|
|
@ -1428,6 +1579,7 @@
|
||||||
window.__wailsMockExternalOpens = [];
|
window.__wailsMockExternalOpens = [];
|
||||||
workspaceTree = makeDefaultWorkspaceTree();
|
workspaceTree = makeDefaultWorkspaceTree();
|
||||||
reloadResponseMode = 'tuple';
|
reloadResponseMode = 'tuple';
|
||||||
|
syncState = makeDefaultSyncState();
|
||||||
},
|
},
|
||||||
setPluginStatus: function (pluginId, status, enabled) {
|
setPluginStatus: function (pluginId, status, enabled) {
|
||||||
if (pluginStates[pluginId]) {
|
if (pluginStates[pluginId]) {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
"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.
|
// App is the main application struct exposed to the Wails frontend.
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
@ -974,8 +979,8 @@ func (a *App) PublishPluginEvent(pluginID, eventName string, payload map[string]
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribePluginEvent validates subscribe permission for a bundled frontend plugin.
|
// SubscribePluginEvent validates subscribe permission and bridges backend events
|
||||||
// Actual bundled event dispatch is handled by the frontend plugin host event bus.
|
// into the bundled frontend plugin host.
|
||||||
func (a *App) SubscribePluginEvent(pluginID, eventName string) string {
|
func (a *App) SubscribePluginEvent(pluginID, eventName string) string {
|
||||||
if _, err := a.requirePluginAccess(pluginID, "events.subscribe"); err != nil {
|
if _, err := a.requirePluginAccess(pluginID, "events.subscribe"); err != nil {
|
||||||
return err.Error()
|
return err.Error()
|
||||||
|
|
@ -983,6 +988,15 @@ func (a *App) SubscribePluginEvent(pluginID, eventName string) string {
|
||||||
if eventName == "" {
|
if eventName == "" {
|
||||||
return "event name is empty"
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1513,7 +1527,7 @@ func (a *App) syncStatus() (*SyncStatusDTO, error) {
|
||||||
dto.UnpushedOps = len(unpushed)
|
dto.UnpushedOps = len(unpushed)
|
||||||
|
|
||||||
if deviceToken != "" {
|
if deviceToken != "" {
|
||||||
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
client := newSyncClient(serverURL, "", "", vaultPath)
|
||||||
client.DeviceToken = deviceToken
|
client.DeviceToken = deviceToken
|
||||||
if cfg.Sync.DeviceID != "" {
|
if cfg.Sync.DeviceID != "" {
|
||||||
client.DeviceID = cfg.Sync.DeviceID
|
client.DeviceID = cfg.Sync.DeviceID
|
||||||
|
|
@ -1570,7 +1584,7 @@ func (a *App) syncConfigure(serverURL, username, password string) error {
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = "unknown"
|
hostname = "unknown"
|
||||||
}
|
}
|
||||||
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
client := newSyncClient(serverURL, "", "", vaultPath)
|
||||||
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2")
|
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("pair: %w", err)
|
return fmt.Errorf("pair: %w", err)
|
||||||
|
|
@ -1613,7 +1627,7 @@ func (a *App) syncDisconnect() error {
|
||||||
cfg := a.appSettings.Get()
|
cfg := a.appSettings.Get()
|
||||||
|
|
||||||
if deviceToken != "" {
|
if deviceToken != "" {
|
||||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", vaultPath)
|
client := newSyncClient(cfg.Sync.ServerURL, "", "", vaultPath)
|
||||||
client.DeviceToken = deviceToken
|
client.DeviceToken = deviceToken
|
||||||
_ = client.RevokeCurrent()
|
_ = client.RevokeCurrent()
|
||||||
}
|
}
|
||||||
|
|
@ -1647,7 +1661,7 @@ func (a *App) syncTestConnection(serverURL, username, password string) error {
|
||||||
if vaultPath == "" {
|
if vaultPath == "" {
|
||||||
vaultPath = "/tmp"
|
vaultPath = "/tmp"
|
||||||
}
|
}
|
||||||
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
client := newSyncClient(serverURL, "", "", vaultPath)
|
||||||
return client.TestAuth(serverURL, username, password)
|
return client.TestAuth(serverURL, username, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1703,7 +1717,7 @@ func (a *App) syncNow() (map[string]interface{}, error) {
|
||||||
deviceID = cfg.Sync.DeviceID
|
deviceID = cfg.Sync.DeviceID
|
||||||
}
|
}
|
||||||
|
|
||||||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, vaultPath)
|
client := newSyncClient(serverURL, apiKey, deviceID, vaultPath)
|
||||||
client.DeviceToken = deviceToken
|
client.DeviceToken = deviceToken
|
||||||
|
|
||||||
unpushed, err := a.syncSvc.GetUnpushedOps()
|
unpushed, err := a.syncSvc.GetUnpushedOps()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -21,6 +23,20 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
"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.
|
// newTestApp creates an App with a mocked plugin list for testing.
|
||||||
func newTestApp(tmpRoot string) *App {
|
func newTestApp(tmpRoot string) *App {
|
||||||
return &App{
|
return &App{
|
||||||
|
|
@ -738,7 +754,7 @@ func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) {
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
var pushedDeviceID string
|
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" {
|
if r.Header.Get("Authorization") != "Bearer device-token" {
|
||||||
http.Error(w, "missing auth", http.StatusUnauthorized)
|
http.Error(w, "missing auth", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
|
|
@ -1291,3 +1307,55 @@ func TestPluginBridgeCapabilitiesCommandsAndEventsAreChecked(t *testing.T) {
|
||||||
t.Fatal("expected command permission/ownership error")
|
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)
|
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).
|
// 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.
|
// For now, a simple version: clear all handlers for a given event name.
|
||||||
func (b *Bus) Unsubscribe(event string) {
|
func (b *Bus) Unsubscribe(event string) {
|
||||||
|
|
|
||||||
11
main.go
11
main.go
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/api"
|
"github.com/verstak/verstak-desktop/internal/api"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
"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/capability"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
|
|
@ -251,9 +252,17 @@ func main() {
|
||||||
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
|
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
|
||||||
}
|
}
|
||||||
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled)
|
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 ───────────────────────────────────────────
|
// ─── Wails App ───────────────────────────────────────────
|
||||||
err := wails.Run(&options.App{
|
err = wails.Run(&options.App{
|
||||||
Title: "Verstak",
|
Title: "Verstak",
|
||||||
Width: 1200,
|
Width: 1200,
|
||||||
Height: 800,
|
Height: 800,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ report "go mod download" $?
|
||||||
report "go vet" $?
|
report "go vet" $?
|
||||||
|
|
||||||
# ── Go fmt (non-destructive — only report unformatted files) ──
|
# ── 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
|
if [ -z "$UNFORMATTED" ]; then
|
||||||
echo " ✅ gofmt: all files formatted"
|
echo " ✅ gofmt: all files formatted"
|
||||||
else
|
else
|
||||||
|
|
@ -58,15 +58,17 @@ report "go mod tidy" $?
|
||||||
echo "[frontend]"
|
echo "[frontend]"
|
||||||
if ensure_npm_deps "$ROOT/frontend"; then
|
if ensure_npm_deps "$ROOT/frontend"; then
|
||||||
if grep -q '"lint"' "$ROOT/frontend/package.json" 2>/dev/null; then
|
if grep -q '"lint"' "$ROOT/frontend/package.json" 2>/dev/null; then
|
||||||
(cd "$ROOT/frontend" && npm run lint 2>&1 || true)
|
FRONTEND_LINT_STATUS=0
|
||||||
report "frontend lint" $?
|
(cd "$ROOT/frontend" && npm run lint 2>&1) || FRONTEND_LINT_STATUS=$?
|
||||||
|
report "frontend lint" "$FRONTEND_LINT_STATUS"
|
||||||
else
|
else
|
||||||
echo " ℹ️ no lint script in frontend/package.json"
|
echo " ℹ️ no lint script in frontend/package.json"
|
||||||
fi
|
fi
|
||||||
# Always run tsc --noEmit if typescript is available
|
# Always run tsc --noEmit if typescript is available
|
||||||
if [ -f "$ROOT/frontend/node_modules/.bin/tsc" ]; then
|
if [ -f "$ROOT/frontend/node_modules/.bin/tsc" ]; then
|
||||||
(cd "$ROOT/frontend" && npx tsc --noEmit 2>&1 || true)
|
FRONTEND_TSC_STATUS=0
|
||||||
report "frontend tsc --noEmit" $?
|
(cd "$ROOT/frontend" && npx tsc --noEmit 2>&1) || FRONTEND_TSC_STATUS=$?
|
||||||
|
report "frontend tsc --noEmit" "$FRONTEND_TSC_STATUS"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo " ℹ️ no frontend/package.json"
|
echo " ℹ️ no frontend/package.json"
|
||||||
|
|
|
||||||
|
|
@ -34,16 +34,18 @@ echo "=== verstak-desktop test ==="
|
||||||
|
|
||||||
# ── Go tests ──
|
# ── Go tests ──
|
||||||
(cd "$ROOT" && go mod download)
|
(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
|
echo "$OUTPUT" | grep -E '(FAIL|PASS|---)' || true
|
||||||
report "go test" $?
|
report "go test" "$GO_TEST_STATUS"
|
||||||
|
|
||||||
# ── Frontend tests ──
|
# ── Frontend tests ──
|
||||||
echo "[frontend]"
|
echo "[frontend]"
|
||||||
if ensure_npm_deps "$ROOT/frontend"; then
|
if ensure_npm_deps "$ROOT/frontend"; then
|
||||||
if grep -q '"test"' "$ROOT/frontend/package.json" 2>/dev/null; then
|
if grep -q '"test"' "$ROOT/frontend/package.json" 2>/dev/null; then
|
||||||
(cd "$ROOT/frontend" && npm test 2>&1 || true)
|
FRONTEND_TEST_STATUS=0
|
||||||
report "frontend test" $?
|
(cd "$ROOT/frontend" && npm test 2>&1) || FRONTEND_TEST_STATUS=$?
|
||||||
|
report "frontend test" "$FRONTEND_TEST_STATUS"
|
||||||
else
|
else
|
||||||
echo " ℹ️ no test script in frontend/package.json"
|
echo " ℹ️ no test script in frontend/package.json"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue