901 lines
28 KiB
JavaScript
901 lines
28 KiB
JavaScript
/**
|
|
* Background service worker: maintains native messaging port and routes messages
|
|
* between content scripts and the native host. Reconnects with backoff on disconnect.
|
|
*/
|
|
|
|
importScripts('origin-allowlist.js');
|
|
|
|
const HOST_NAME = 'com.bridgeswarm';
|
|
const MAX_RECONNECT_DELAY = 30000;
|
|
const INITIAL_RECONNECT_DELAY = 100;
|
|
const MAX_LOGS = 500;
|
|
const SETTINGS_KEY = 'bridgeSwarmSettings';
|
|
const DASHBOARD_PORT = 'bridgeswarm-dashboard';
|
|
|
|
const browser =
|
|
typeof chrome !== 'undefined' && chrome.runtime?.connectNative
|
|
? chrome
|
|
: typeof browser !== 'undefined'
|
|
? browser
|
|
: chrome;
|
|
|
|
let port = null;
|
|
let reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
let reconnectTimer = null;
|
|
let reconnectAttempt = 0;
|
|
let lastDisconnectReason = null;
|
|
let hostSnapshot = null;
|
|
let hostSnapshotAt = 0;
|
|
|
|
const pending = new Map();
|
|
const subscribedTabs = new Set();
|
|
const tabSwarms = new Map();
|
|
const swarmRefCount = new Map();
|
|
/** swarmId -> { swarmId, appName, createdAt } */
|
|
const swarmMeta = new Map();
|
|
/** connId -> { connId, swarmId, peerKey, topics, createdAt } */
|
|
const activeConnections = new Map();
|
|
|
|
const logs = [];
|
|
let logSeq = 0;
|
|
const dashboardPorts = new Set();
|
|
|
|
let notifyOnDisconnect = false;
|
|
let debugMode = false;
|
|
let examplesServerEnabled = false;
|
|
let qvacEnabled = false;
|
|
let examplesServerPort = 4173;
|
|
let defaultFirewall = { mode: 'off', keys: [] };
|
|
let cachedSettings = {};
|
|
let capabilityOrigins = [];
|
|
let agentAlwaysApproveOrigins = [];
|
|
let agentWorkspaceRoots = [];
|
|
|
|
function examplesUrl() {
|
|
return `http://127.0.0.1:${examplesServerPort || 4173}/`;
|
|
}
|
|
|
|
let examplesServerState = {
|
|
running: false,
|
|
url: null,
|
|
error: null,
|
|
rootFound: null,
|
|
};
|
|
|
|
const extensionState = {
|
|
hostConnected: false,
|
|
swarms: new Map(),
|
|
stats: {
|
|
totalConnections: 0,
|
|
totalSwarms: 0,
|
|
uptime: Date.now(),
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Expected Hyperswarm/UDX peer churn. Must not use console.error — Chrome's
|
|
* extension Errors page only lists console.error and makes the extension look broken.
|
|
*/
|
|
const BENIGN_CONN_RE =
|
|
/connection reset by peer|connection timed out|stream timed out|duplicate connection|swarm has been destroyed|ECONNRESET|ETIMEDOUT|EPIPE|ENOTCONN|ECONNABORTED|socket hang up|not connected|Write after end|destroyed|HOLEPUNCH_|CANNOT_HOLEPUNCH|REMOTE_NOT_HOLEPUNCHABLE|CHANNEL_CLOSED|STREAM_NOT_CONNECTED/i;
|
|
|
|
function isBenignConnectionMessage(message) {
|
|
return BENIGN_CONN_RE.test(String(message || ''));
|
|
}
|
|
|
|
function log(levelOrMsg, ...rest) {
|
|
let level = 'info';
|
|
let args;
|
|
if (typeof levelOrMsg === 'string' && ['debug', 'info', 'warn', 'error'].includes(levelOrMsg) && rest.length) {
|
|
level = levelOrMsg;
|
|
args = rest;
|
|
} else {
|
|
args = [levelOrMsg, ...rest];
|
|
}
|
|
|
|
if (level === 'debug' && !debugMode) return;
|
|
|
|
const message = args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
|
|
|
|
// Downgrade expected peer disconnects so they never flood chrome://extensions errors
|
|
if (level === 'error' && isBenignConnectionMessage(message)) {
|
|
level = 'info';
|
|
}
|
|
|
|
const meta = {};
|
|
const swarmMatch = message.match(/swarm[_-][a-zA-Z0-9]+/);
|
|
const connMatch = message.match(/conn_[a-zA-Z0-9]+/);
|
|
const reqMatch = message.match(/req_[a-zA-Z0-9]+/);
|
|
const jobMatch = message.match(/(?:job|session)_[a-zA-Z0-9]+/);
|
|
if (swarmMatch) meta.swarmId = swarmMatch[0];
|
|
if (connMatch) meta.connId = connMatch[0];
|
|
if (reqMatch) meta.requestId = reqMatch[0];
|
|
if (jobMatch) meta.jobId = jobMatch[0];
|
|
|
|
let category = 'host';
|
|
const lower = message.toLowerCase();
|
|
if (lower.includes('examples')) category = 'examples';
|
|
else if (lower.includes('cap-') || lower.includes('capability') || lower.includes('media')) category = 'cap';
|
|
else if (lower.includes('connection') || lower.includes('conn_')) category = 'conn';
|
|
else if (lower.includes('swarm') || lower.includes('register')) category = 'swarm';
|
|
else if (lower.includes('setting') || lower.includes('firewall')) category = 'settings';
|
|
|
|
const entry = {
|
|
id: `log_${Date.now()}_${++logSeq}`,
|
|
ts: Date.now(),
|
|
timestamp: Date.now(),
|
|
level,
|
|
category,
|
|
message,
|
|
meta,
|
|
};
|
|
logs.push(entry);
|
|
if (logs.length > MAX_LOGS) logs.shift();
|
|
|
|
if (level === 'error') console.error('[BridgeSwarm-bg]', message);
|
|
else if (level === 'warn') console.warn('[BridgeSwarm-bg]', message);
|
|
else console.log('[BridgeSwarm-bg]', message);
|
|
|
|
broadcastLogs(entry);
|
|
}
|
|
|
|
function broadcastLogs(entry) {
|
|
const msg = entry
|
|
? { type: 'bridge-swarm-logs', entry, logs: logs.slice() }
|
|
: { type: 'bridge-swarm-logs', logs: logs.slice() };
|
|
for (const p of dashboardPorts) {
|
|
try {
|
|
p.postMessage(msg);
|
|
} catch (_) {
|
|
dashboardPorts.delete(p);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateExtensionState() {
|
|
extensionState.hostConnected = !!port;
|
|
extensionState.stats.totalConnections = activeConnections.size;
|
|
extensionState.swarms.clear();
|
|
for (const [swarmId, count] of swarmRefCount) {
|
|
const meta = swarmMeta.get(swarmId) || {};
|
|
const connections = [];
|
|
for (const conn of activeConnections.values()) {
|
|
if (conn.swarmId === swarmId) connections.push(conn);
|
|
}
|
|
const snap = hostSnapshot?.swarms?.find((s) => s.swarmId === swarmId);
|
|
extensionState.swarms.set(swarmId, {
|
|
swarmId,
|
|
appName: meta.appName || snap?.appName || 'bridge-swarm',
|
|
tabCount: count,
|
|
connections,
|
|
createdAt: meta.createdAt || Date.now(),
|
|
publicKey: snap?.publicKey || null,
|
|
firewall: snap?.firewall || null,
|
|
autoReplicate: snap?.autoReplicate || false,
|
|
peerCount: snap?.peerCount ?? connections.length,
|
|
});
|
|
}
|
|
extensionState.stats.totalSwarms = extensionState.swarms.size;
|
|
}
|
|
|
|
function clearSwarmLocal(swarmId) {
|
|
swarmRefCount.delete(swarmId);
|
|
swarmMeta.delete(swarmId);
|
|
for (const [connId, conn] of [...activeConnections]) {
|
|
if (conn.swarmId === swarmId) activeConnections.delete(connId);
|
|
}
|
|
for (const [tabId, swarmIds] of tabSwarms) {
|
|
swarmIds.delete(swarmId);
|
|
if (swarmIds.size === 0) tabSwarms.delete(tabId);
|
|
}
|
|
updateExtensionState();
|
|
}
|
|
|
|
function applySettingsFromStorage(s) {
|
|
cachedSettings = s || {};
|
|
notifyOnDisconnect = s.notifyOnDisconnect === true;
|
|
debugMode = s.debug === true;
|
|
examplesServerPort = typeof s.examplesServerPort === 'number' && s.examplesServerPort > 0 ? s.examplesServerPort : 4173;
|
|
defaultFirewall = {
|
|
mode: s.defaultFirewallMode || 'off',
|
|
keys: Array.isArray(s.defaultFirewallKeys) ? s.defaultFirewallKeys.filter(Boolean) : [],
|
|
};
|
|
const allow = self.BridgeSwarmOriginAllowlist;
|
|
capabilityOrigins = allow ? allow.allowedOrigins(s) : [];
|
|
agentAlwaysApproveOrigins = allow ? allow.parseOriginList(s.agentAlwaysApproveOrigins) : [];
|
|
agentWorkspaceRoots = Array.isArray(s.agentWorkspaceRoots)
|
|
? s.agentWorkspaceRoots.map((p) => String(p || '').trim()).filter(Boolean)
|
|
: allow
|
|
? allow.parseOriginList(s.agentWorkspaceRoots).filter((x) => x && !x.startsWith('http'))
|
|
: [];
|
|
if (typeof s.agentWorkspaceRoots === 'string') {
|
|
agentWorkspaceRoots = s.agentWorkspaceRoots
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
}
|
|
syncAgentGrants().catch(() => {});
|
|
|
|
const wantExamples = s.examplesServerEnabled === true;
|
|
if (wantExamples !== examplesServerEnabled) {
|
|
examplesServerEnabled = wantExamples;
|
|
syncExamplesServer().catch((err) => log('error', 'examples server sync failed:', err.message || err));
|
|
} else if (wantExamples && port) {
|
|
examplesServerEnabled = wantExamples;
|
|
syncExamplesServer().catch(() => {});
|
|
} else {
|
|
examplesServerEnabled = wantExamples;
|
|
}
|
|
|
|
const wantQvac = s.qvacEnabled === true;
|
|
if (wantQvac !== qvacEnabled) {
|
|
qvacEnabled = wantQvac;
|
|
log('info', wantQvac ? 'QVAC enabled' : 'QVAC disabled');
|
|
syncQvacEnabled().catch((err) => log('error', 'QVAC enable sync failed:', err.message || err));
|
|
} else if (port) {
|
|
qvacEnabled = wantQvac;
|
|
syncQvacEnabled().catch(() => {});
|
|
} else {
|
|
qvacEnabled = wantQvac;
|
|
}
|
|
}
|
|
|
|
function loadSettings() {
|
|
browser.storage.local.get(SETTINGS_KEY, (result) => {
|
|
applySettingsFromStorage(result[SETTINGS_KEY] || {});
|
|
});
|
|
}
|
|
|
|
async function syncExamplesServer() {
|
|
const url = examplesUrl();
|
|
if (!port) {
|
|
examplesServerState = {
|
|
running: false,
|
|
url: examplesServerEnabled ? url : null,
|
|
error: examplesServerEnabled ? 'Native host not connected' : null,
|
|
rootFound: null,
|
|
};
|
|
return examplesServerState;
|
|
}
|
|
try {
|
|
if (examplesServerEnabled) {
|
|
const res = await send(
|
|
{ type: 'examplesServer.start', payload: { host: '127.0.0.1', port: examplesServerPort || 4173 } },
|
|
{ quiet: !debugMode }
|
|
);
|
|
examplesServerState = {
|
|
running: !!res?.running,
|
|
url: res?.url || url,
|
|
error: res?.ok === false ? res.error || 'Failed to start' : null,
|
|
rootFound: res?.rootFound,
|
|
};
|
|
if (res?.ok !== false) log('info', 'Examples server:', examplesServerState.url);
|
|
else log('error', 'Examples server failed:', examplesServerState.error);
|
|
} else {
|
|
const res = await send({ type: 'examplesServer.stop', payload: {} }, { quiet: !debugMode });
|
|
examplesServerState = {
|
|
running: false,
|
|
url: null,
|
|
error: res?.ok === false ? res.error || null : null,
|
|
rootFound: res?.rootFound,
|
|
};
|
|
log('info', 'Examples server stopped');
|
|
}
|
|
} catch (err) {
|
|
examplesServerState = {
|
|
running: false,
|
|
url: examplesServerEnabled ? url : null,
|
|
error: err.message || String(err),
|
|
rootFound: null,
|
|
};
|
|
log('error', 'Examples server error:', err.message || err);
|
|
}
|
|
return examplesServerState;
|
|
}
|
|
|
|
async function syncQvacEnabled() {
|
|
if (!port) return;
|
|
try {
|
|
const res = await send(
|
|
{
|
|
type: 'capability',
|
|
payload: {
|
|
pack: 'qvac',
|
|
cmd: 'setEnabled',
|
|
payload: { enabled: qvacEnabled === true },
|
|
},
|
|
},
|
|
{ quiet: true }
|
|
);
|
|
if (res && res.ok === false) {
|
|
log('warn', 'QVAC setEnabled:', res.error || 'failed');
|
|
}
|
|
} catch (err) {
|
|
log('warn', 'QVAC setEnabled failed:', err.message || err);
|
|
}
|
|
}
|
|
|
|
async function refreshHostSnapshot(force) {
|
|
if (!port) {
|
|
hostSnapshot = null;
|
|
return null;
|
|
}
|
|
if (!force && hostSnapshot && Date.now() - hostSnapshotAt < 2000) return hostSnapshot;
|
|
try {
|
|
const snap = await send({ type: 'host.snapshot', payload: {} }, { quiet: true });
|
|
if (snap && snap.ok !== false) {
|
|
hostSnapshot = snap;
|
|
hostSnapshotAt = Date.now();
|
|
if (snap.examples) {
|
|
examplesServerState = {
|
|
running: !!snap.examples.running,
|
|
url: snap.examples.url || (examplesServerEnabled ? examplesUrl() : null),
|
|
error: snap.examples.error || null,
|
|
rootFound: snap.examples.rootFound,
|
|
};
|
|
}
|
|
}
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
return hostSnapshot;
|
|
}
|
|
|
|
async function applyDefaultFirewall(swarmId) {
|
|
if (!defaultFirewall || defaultFirewall.mode === 'off') return;
|
|
try {
|
|
await send(
|
|
{
|
|
type: 'setFirewall',
|
|
payload: {
|
|
swarmId,
|
|
mode: defaultFirewall.mode,
|
|
keys: defaultFirewall.keys || [],
|
|
},
|
|
},
|
|
{ quiet: !debugMode }
|
|
);
|
|
log('info', 'Applied default firewall', defaultFirewall.mode, 'to', swarmId);
|
|
} catch (err) {
|
|
log('warn', 'Failed to apply default firewall:', err.message || err);
|
|
}
|
|
}
|
|
|
|
function senderPageOrigin(sender) {
|
|
const allow = self.BridgeSwarmOriginAllowlist;
|
|
if (!allow) return '';
|
|
const url = (sender && sender.url) || (sender && sender.tab && sender.tab.url) || '';
|
|
if (sender && sender.origin && (sender.origin.startsWith('http') || sender.origin.startsWith('chrome-extension') || sender.origin.startsWith('moz-extension'))) {
|
|
return allow.normalizeOrigin(sender.origin);
|
|
}
|
|
return allow.originFromUrl(url);
|
|
}
|
|
|
|
function extensionOrigin() {
|
|
try {
|
|
return new URL(browser.runtime.getURL('')).origin;
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deny pack commands from origins not on the capability allowlist.
|
|
* Attaches _origin / _alwaysApprove onto the inner payload for the host.
|
|
* Returns an error response object, or null if the message may proceed.
|
|
*/
|
|
function gateCapabilityPayload(msg, sender) {
|
|
const allow = self.BridgeSwarmOriginAllowlist;
|
|
if (!allow || !msg || !allow.isCapabilityType(msg.type)) return null;
|
|
const origin = senderPageOrigin(sender);
|
|
const extOrigin = extensionOrigin();
|
|
if (!allow.isOriginAllowed(origin, cachedSettings, { extensionOrigin: extOrigin })) {
|
|
log('warn', 'Blocked capability from origin', origin || '(unknown)', msg.type);
|
|
return { ok: false, error: 'origin not allowed for capabilities: ' + (origin || 'unknown') };
|
|
}
|
|
if (allow.isQvacDisabled(msg, cachedSettings)) {
|
|
log('warn', 'Blocked QVAC/agent (disabled)', msg.type);
|
|
return { ok: false, error: 'QVAC is disabled. Enable it in BridgeSwarm Settings.' };
|
|
}
|
|
const inner = msg.payload && typeof msg.payload === 'object' ? msg.payload : {};
|
|
const always = allow.isAlwaysApproveOrigin(origin, cachedSettings);
|
|
if (msg.type === 'capability') {
|
|
const nested = inner.payload && typeof inner.payload === 'object' ? inner.payload : {};
|
|
nested._origin = origin;
|
|
nested._alwaysApprove = always;
|
|
inner.payload = nested;
|
|
inner._origin = origin;
|
|
msg.payload = inner;
|
|
} else {
|
|
inner._origin = origin;
|
|
inner._alwaysApprove = always;
|
|
msg.payload = inner;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function syncAgentGrants() {
|
|
if (!port) return;
|
|
try {
|
|
await send(
|
|
{
|
|
type: 'capability',
|
|
payload: {
|
|
pack: 'agent',
|
|
cmd: 'setGrants',
|
|
payload: {
|
|
roots: agentWorkspaceRoots,
|
|
alwaysApproveOrigins: agentAlwaysApproveOrigins,
|
|
_origin: extensionOrigin(),
|
|
_alwaysApprove: true,
|
|
},
|
|
},
|
|
},
|
|
{ quiet: true }
|
|
);
|
|
} catch (_) {
|
|
/* agent pack may not be registered yet */
|
|
}
|
|
}
|
|
|
|
browser.storage.onChanged.addListener((changes, areaName) => {
|
|
if (areaName === 'local' && changes[SETTINGS_KEY]) {
|
|
applySettingsFromStorage(changes[SETTINGS_KEY].newValue || {});
|
|
}
|
|
});
|
|
|
|
function connect() {
|
|
try {
|
|
port = browser.runtime.connectNative(HOST_NAME);
|
|
log('info', 'Connected to native host');
|
|
reconnectAttempt = 0;
|
|
lastDisconnectReason = null;
|
|
} catch (e) {
|
|
log('error', 'connectNative failed:', e.message || e);
|
|
scheduleReconnect();
|
|
return;
|
|
}
|
|
|
|
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
if (examplesServerEnabled) {
|
|
syncExamplesServer().catch(() => {});
|
|
}
|
|
syncQvacEnabled().catch(() => {});
|
|
syncAgentGrants().catch(() => {});
|
|
refreshHostSnapshot(true).catch(() => {});
|
|
|
|
port.onMessage.addListener((msg) => {
|
|
if (debugMode) log('debug', 'Received from native:', msg.type, msg.event || '');
|
|
|
|
if (msg.type === 'event') {
|
|
const payload = msg.payload || {};
|
|
if (msg.event === 'connection') {
|
|
const connId = payload.connId;
|
|
const swarmId = payload.swarmId;
|
|
const peerInfo = payload.peerInfo || {};
|
|
log('info', 'Connection established:', connId, 'peer:', (peerInfo.publicKey || '').slice(0, 8));
|
|
activeConnections.set(connId, {
|
|
connId,
|
|
swarmId,
|
|
peerKey: peerInfo.publicKey || '',
|
|
topics: peerInfo.topics || [],
|
|
createdAt: Date.now(),
|
|
});
|
|
updateExtensionState();
|
|
broadcastState();
|
|
} else if (msg.event === 'error') {
|
|
const connId = payload.connId;
|
|
const errMsg = payload.message || 'Unknown error';
|
|
// Host only emits error for non-transient failures; still guard Chrome error spam
|
|
if (isBenignConnectionMessage(errMsg)) {
|
|
log('info', 'Connection closed:', connId, errMsg);
|
|
} else {
|
|
log('error', 'Connection ERROR:', connId, errMsg);
|
|
}
|
|
activeConnections.delete(connId);
|
|
updateExtensionState();
|
|
broadcastState();
|
|
} else if (msg.event === 'end') {
|
|
const reason = payload.reason ? ` (${payload.reason})` : '';
|
|
log('info', 'Connection ended:', payload.connId + reason);
|
|
activeConnections.delete(payload.connId);
|
|
updateExtensionState();
|
|
broadcastState();
|
|
} else if (msg.event === 'cap-error') {
|
|
log('error', 'Capability error:', payload.pack, payload.message || '');
|
|
} else if (msg.event === 'cap-end') {
|
|
if (debugMode) log('debug', 'Capability end:', payload.pack, payload.jobId || payload.sessionId || '');
|
|
}
|
|
}
|
|
|
|
if (msg.type === 'response' && msg.id != null) {
|
|
const p = pending.get(msg.id);
|
|
if (p) {
|
|
pending.delete(msg.id);
|
|
if (msg.payload && msg.payload.error) {
|
|
p.reject(new Error(msg.payload.error));
|
|
} else {
|
|
p.resolve(msg.payload);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'event') {
|
|
const eventSwarmId = msg.payload?.swarmId;
|
|
const isCapEvent =
|
|
msg.event === 'cap-chunk' ||
|
|
msg.event === 'cap-end' ||
|
|
msg.event === 'cap-error' ||
|
|
!!msg.payload?.pack;
|
|
if (debugMode) {
|
|
log('debug', 'Broadcasting event to tabs:', msg.event, 'connId:', msg.payload?.connId, 'swarmId:', eventSwarmId);
|
|
}
|
|
if (isCapEvent || eventSwarmId == null) {
|
|
for (const tabId of subscribedTabs) {
|
|
browser.tabs.sendMessage(tabId, { type: 'bridge-swarm-event', payload: msg }).catch(() => {});
|
|
}
|
|
} else {
|
|
for (const [tabId, swarmIds] of tabSwarms) {
|
|
if (swarmIds.has(eventSwarmId)) {
|
|
browser.tabs.sendMessage(tabId, { type: 'bridge-swarm-event', payload: msg }).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
port.onDisconnect.addListener((p) => {
|
|
const reason = p?.error?.message || browser.runtime.lastError?.message || null;
|
|
lastDisconnectReason = reason || 'Native host disconnected';
|
|
if (reason) log('warn', 'onDisconnect reason:', reason);
|
|
else log('warn', 'Native host disconnected');
|
|
extensionState.hostConnected = false;
|
|
activeConnections.clear();
|
|
hostSnapshot = null;
|
|
updateExtensionState();
|
|
port = null;
|
|
examplesServerState = {
|
|
running: false,
|
|
url: examplesServerEnabled ? examplesUrl() : null,
|
|
error: examplesServerEnabled ? 'Native host disconnected' : null,
|
|
rootFound: examplesServerState.rootFound,
|
|
};
|
|
broadcastState();
|
|
if (notifyOnDisconnect && browser.notifications) {
|
|
browser.notifications
|
|
.create('bridgeswarm-host-disconnect', {
|
|
type: 'basic',
|
|
title: 'BridgeSwarm',
|
|
message: 'Native host disconnected.',
|
|
iconUrl: browser.runtime.getURL('icons/48.png'),
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
const err = new Error(reason || 'Native host disconnected');
|
|
for (const [, pendingReq] of pending) {
|
|
pendingReq.reject(err);
|
|
}
|
|
pending.clear();
|
|
for (const tabId of subscribedTabs) {
|
|
browser.tabs.sendMessage(tabId, { type: 'bridge-swarm-host-disconnect' }).catch(() => {});
|
|
}
|
|
scheduleReconnect();
|
|
});
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
if (reconnectTimer) return;
|
|
reconnectAttempt += 1;
|
|
const delay = reconnectDelay;
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
}, delay);
|
|
broadcastState();
|
|
}
|
|
|
|
function send(msg, opts = {}) {
|
|
if (!opts.quiet && debugMode) {
|
|
log('debug', 'Sending to native:', msg.type, msg.payload?.swarmId || '', msg.payload?.connId || '');
|
|
} else if (!opts.quiet && !debugMode && !['host.snapshot', 'examplesServer.status'].includes(msg.type)) {
|
|
/* skip chatty */
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
if (!port) {
|
|
reject(new Error('Native host not connected'));
|
|
return;
|
|
}
|
|
const id = msg.id || `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
msg.id = id;
|
|
pending.set(id, { resolve, reject });
|
|
try {
|
|
port.postMessage(msg);
|
|
} catch (e) {
|
|
pending.delete(id);
|
|
reject(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildStatePayload() {
|
|
updateExtensionState();
|
|
const tabsInfo = [];
|
|
for (const [tabId, swarmIds] of tabSwarms) {
|
|
tabsInfo.push({ tabId, swarmIds: Array.from(swarmIds) });
|
|
}
|
|
const manifest = browser.runtime.getManifest?.() || {};
|
|
return {
|
|
hostConnected: extensionState.hostConnected,
|
|
swarms: Array.from(extensionState.swarms.values()),
|
|
tabs: tabsInfo,
|
|
stats: {
|
|
totalConnections: extensionState.stats.totalConnections,
|
|
totalSwarms: extensionState.stats.totalSwarms,
|
|
uptime: extensionState.stats.uptime,
|
|
pendingRequests: pending.size,
|
|
reconnectAttempt,
|
|
reconnectDelayMs: port ? 0 : reconnectDelay,
|
|
},
|
|
health: {
|
|
lastDisconnectReason,
|
|
reconnectAttempt,
|
|
reconnectDelayMs: port ? 0 : reconnectDelay,
|
|
extensionVersion: manifest.version || '1.0.0',
|
|
hostVersion: hostSnapshot?.version || null,
|
|
storagePath: hostSnapshot?.storagePath || null,
|
|
packs: hostSnapshot?.packs || [],
|
|
qvac: hostSnapshot?.qvac || null,
|
|
agent: hostSnapshot?.agent || null,
|
|
openai: hostSnapshot?.openai || null,
|
|
},
|
|
examples: {
|
|
enabled: examplesServerEnabled,
|
|
...examplesServerState,
|
|
url: examplesServerState.url || (examplesServerEnabled ? examplesUrl() : null),
|
|
port: examplesServerPort,
|
|
},
|
|
hostSnapshot,
|
|
};
|
|
}
|
|
|
|
function broadcastState() {
|
|
const base = buildStatePayload();
|
|
enrichTabs(base.tabs)
|
|
.then((tabs) => {
|
|
const state = { ...base, tabs };
|
|
for (const p of dashboardPorts) {
|
|
try {
|
|
p.postMessage({ type: 'bridge-swarm-state', state });
|
|
} catch (_) {
|
|
dashboardPorts.delete(p);
|
|
}
|
|
}
|
|
})
|
|
.catch(() => {
|
|
for (const p of dashboardPorts) {
|
|
try {
|
|
p.postMessage({ type: 'bridge-swarm-state', state: base });
|
|
} catch (_) {
|
|
dashboardPorts.delete(p);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function enrichTabs(tabsInfo) {
|
|
const enriched = [];
|
|
for (const t of tabsInfo) {
|
|
let title = null;
|
|
let url = null;
|
|
try {
|
|
const tab = await browser.tabs.get(t.tabId);
|
|
title = tab?.title || null;
|
|
url = tab?.url || null;
|
|
} catch (_) {}
|
|
enriched.push({ ...t, title, url });
|
|
}
|
|
return enriched;
|
|
}
|
|
|
|
loadSettings();
|
|
connect();
|
|
|
|
browser.tabs.onRemoved.addListener((tabId) => {
|
|
log('info', 'Tab closed:', tabId);
|
|
const swarmIds = tabSwarms.get(tabId);
|
|
if (swarmIds) {
|
|
for (const swarmId of swarmIds) {
|
|
const count = (swarmRefCount.get(swarmId) || 1) - 1;
|
|
swarmRefCount.set(swarmId, count);
|
|
log('debug', 'Swarm', swarmId, 'refcount now:', count);
|
|
if (count <= 0) {
|
|
log('info', 'Last tab for swarm', swarmId, '- destroying');
|
|
send({ type: 'destroy', payload: { swarmId } }).catch(() => {});
|
|
clearSwarmLocal(swarmId);
|
|
}
|
|
}
|
|
tabSwarms.delete(tabId);
|
|
}
|
|
subscribedTabs.delete(tabId);
|
|
broadcastState();
|
|
});
|
|
|
|
browser.runtime.onConnect.addListener((p) => {
|
|
if (p.name !== DASHBOARD_PORT) return;
|
|
dashboardPorts.add(p);
|
|
log('info', 'Dashboard port connected');
|
|
p.postMessage({ type: 'bridge-swarm-logs', logs: logs.slice() });
|
|
p.postMessage({ type: 'bridge-swarm-state', state: buildStatePayload() });
|
|
p.onDisconnect.addListener(() => {
|
|
dashboardPorts.delete(p);
|
|
});
|
|
p.onMessage.addListener((msg) => {
|
|
if (!msg || msg.target !== 'bridge-swarm-native') return;
|
|
handleDashboardMessage(msg, (response) => {
|
|
try {
|
|
p.postMessage({ type: 'bridge-swarm-response', id: msg.id, response });
|
|
} catch (_) {}
|
|
});
|
|
});
|
|
});
|
|
|
|
function handleDashboardMessage(message, sendResponse) {
|
|
if (message.action === 'getLogs') {
|
|
sendResponse({ ok: true, logs: logs.slice() });
|
|
return false;
|
|
}
|
|
if (message.action === 'clearLogs') {
|
|
logs.length = 0;
|
|
broadcastLogs();
|
|
sendResponse({ ok: true, logs: [] });
|
|
return false;
|
|
}
|
|
if (message.action === 'getState') {
|
|
refreshHostSnapshot(false)
|
|
.then(() => enrichTabs(buildStatePayload().tabs))
|
|
.then((tabs) => {
|
|
const state = buildStatePayload();
|
|
state.tabs = tabs;
|
|
sendResponse({ ok: true, state });
|
|
})
|
|
.catch(() => sendResponse({ ok: true, state: buildStatePayload() }));
|
|
return true;
|
|
}
|
|
if (message.action === 'destroySwarm' && message.payload?.swarmId) {
|
|
const swarmId = message.payload.swarmId;
|
|
send({ type: 'destroy', payload: { swarmId } })
|
|
.then((res) => {
|
|
clearSwarmLocal(swarmId);
|
|
broadcastState();
|
|
sendResponse(res && res.ok === false ? res : { ok: true });
|
|
})
|
|
.catch((err) => sendResponse({ ok: false, error: err.message }));
|
|
return true;
|
|
}
|
|
if (message.action === 'examplesServerStatus') {
|
|
syncExamplesServer()
|
|
.then((state) =>
|
|
sendResponse({
|
|
ok: !state.error,
|
|
enabled: examplesServerEnabled,
|
|
...state,
|
|
url: state.url || (examplesServerEnabled ? examplesUrl() : null),
|
|
port: examplesServerPort,
|
|
})
|
|
)
|
|
.catch((err) =>
|
|
sendResponse({
|
|
ok: false,
|
|
enabled: examplesServerEnabled,
|
|
running: false,
|
|
url: examplesServerEnabled ? examplesUrl() : null,
|
|
error: err.message,
|
|
port: examplesServerPort,
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
if (message.action === 'focusTab' && message.payload?.tabId != null) {
|
|
browser.tabs
|
|
.update(message.payload.tabId, { active: true })
|
|
.then(() => sendResponse({ ok: true }))
|
|
.catch((err) => sendResponse({ ok: false, error: err.message }));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (message.target !== 'bridge-swarm-native') {
|
|
return;
|
|
}
|
|
|
|
if (message.action === 'send' && message.payload?.type === 'registerSwarm') {
|
|
const swarmId = message.payload?.payload?.swarmId;
|
|
const appName = message.payload?.payload?.appName || 'bridge-swarm';
|
|
if (swarmId && sender.tab?.id != null) {
|
|
if (!tabSwarms.has(sender.tab.id)) {
|
|
tabSwarms.set(sender.tab.id, new Set());
|
|
}
|
|
const set = tabSwarms.get(sender.tab.id);
|
|
const already = set.has(swarmId);
|
|
set.add(swarmId);
|
|
if (!already) {
|
|
const count = swarmRefCount.get(swarmId) || 0;
|
|
swarmRefCount.set(swarmId, count + 1);
|
|
}
|
|
if (!swarmMeta.has(swarmId)) {
|
|
swarmMeta.set(swarmId, { swarmId, appName, createdAt: Date.now() });
|
|
} else if (appName) {
|
|
swarmMeta.get(swarmId).appName = appName;
|
|
}
|
|
log('info', 'Registered swarm', swarmId, 'appName=', appName, 'for tab', sender.tab.id);
|
|
applyDefaultFirewall(swarmId).catch(() => {});
|
|
updateExtensionState();
|
|
broadcastState();
|
|
}
|
|
sendResponse({ ok: true });
|
|
return false;
|
|
}
|
|
|
|
if (message.action === 'send') {
|
|
const payload = message.payload;
|
|
const capGate = gateCapabilityPayload(payload, sender);
|
|
if (capGate) {
|
|
sendResponse(capGate);
|
|
return false;
|
|
}
|
|
send(payload)
|
|
.then((res) => {
|
|
if (payload?.type === 'init' && payload?.payload?.swarmId) {
|
|
const swarmId = payload.payload.swarmId;
|
|
const appName = payload.payload?.options?.appName;
|
|
if (!swarmMeta.has(swarmId)) {
|
|
swarmMeta.set(swarmId, {
|
|
swarmId,
|
|
appName: appName || 'bridge-swarm',
|
|
createdAt: Date.now(),
|
|
});
|
|
}
|
|
applyDefaultFirewall(swarmId).catch(() => {});
|
|
}
|
|
sendResponse(res);
|
|
})
|
|
.catch((err) => sendResponse({ error: err.message }));
|
|
return true;
|
|
}
|
|
|
|
if (message.action === 'subscribe' && sender.tab && sender.tab.id != null) {
|
|
subscribedTabs.add(sender.tab.id);
|
|
sendResponse({ ok: true });
|
|
return false;
|
|
}
|
|
if (message.action === 'unsubscribe' && sender.tab && sender.tab.id != null) {
|
|
subscribedTabs.delete(sender.tab.id);
|
|
sendResponse({ ok: true });
|
|
return false;
|
|
}
|
|
|
|
// Legacy dashboard registration (prefer Port)
|
|
if (message.action === 'registerDashboard') {
|
|
sendResponse({ ok: true, logs: logs.slice() });
|
|
return false;
|
|
}
|
|
if (message.action === 'unregisterDashboard') {
|
|
sendResponse({ ok: true });
|
|
return false;
|
|
}
|
|
|
|
const handled = handleDashboardMessage(message, sendResponse);
|
|
if (handled === true) return true;
|
|
if (message.action === 'getLogs' || message.action === 'clearLogs' || message.action === 'getState' || message.action === 'destroySwarm' || message.action === 'examplesServerStatus' || message.action === 'focusTab') {
|
|
return handled;
|
|
}
|
|
});
|
|
|
|
browser.action?.onClicked?.addListener(() => {
|
|
browser.tabs.create({ url: 'dashboard.html' });
|
|
});
|