Files
holesail-browser/extension/background/native-messaging.js
T
Raven Scott 4eeae78d9b
CI / Build & Test (push) Successful in 2m56s
chore(logs): demote noisy native messaging logs to debug level
"Sending to native" and "Received from native" are emitted on every
poll cycle and clutter the logs panel. Moved both to debugLog so they
only appear when Debug Mode is enabled in Settings.
2026-03-01 01:19:18 -05:00

251 lines
9.2 KiB
JavaScript

/**
* Native messaging port lifecycle: connect to the native host, reconnect with
* exponential backoff on disconnect, send promise-based requests with timeout,
* and dispatch incoming responses and events to subscribers.
* Depends on: state.js, logs.js, proxy.js
*/
const HOST_NAME = 'com.holesail.browser';
const MAX_RECONNECT_DELAY = 30000;
const INITIAL_RECONNECT_DELAY = 100;
const REQUEST_TIMEOUT_MS = 30000;
let port = null;
let reconnectDelay = INITIAL_RECONNECT_DELAY;
let reconnectTimer = null;
const _retryTimers = [];
// Pending requests: id -> { resolve, reject }
const pending = new Map();
// Tabs that have subscribed to native host events (content script registered)
const subscribedTabs = new Set();
/**
* Send a message to the native host and return a Promise that resolves with the response payload.
* Rejects if the port is not connected, if the request times out (30 s), or if the
* response contains an error field.
* @param {object} msg - Message object; must include a `type` field.
* @returns {Promise<object>} The response payload from the native host.
*/
function send(msg) {
const id = msg.id || `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
msg.id = id;
debugLog('send: id=', id, 'type=', msg.type, 'payload=', msg.payload && JSON.stringify(msg.payload));
debugLog('Sending to native:', msg.type, msg.payload?.swarmId || '', msg.payload?.connId || '');
return new Promise((resolve, reject) => {
if (!port) {
debugLog('send: no port, rejecting');
reject(new Error('Native host not connected'));
return;
}
const timeoutId = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
debugLog('send: timeout for id=', id, 'type=', msg.type);
reject(new Error('Request timed out: ' + msg.type));
}
}, REQUEST_TIMEOUT_MS);
pending.set(id, {
resolve: (v) => { clearTimeout(timeoutId); resolve(v); },
reject: (e) => { clearTimeout(timeoutId); reject(e); }
});
try {
port.postMessage(msg);
} catch (e) {
pending.delete(id);
clearTimeout(timeoutId);
debugLog('send: postMessage threw', e.message);
reject(e);
}
});
}
function retryGetStateForConnectProxy(delaySeconds, attempt = 0) {
const maxAttempts = 3;
if (attempt >= maxAttempts || !port) return;
const delayMs = delaySeconds * (attempt + 1) * 1000;
const timerId = setTimeout(() => {
const idx = _retryTimers.indexOf(timerId);
if (idx !== -1) _retryTimers.splice(idx, 1);
if (!port) return;
send({ type: 'getState', payload: {} })
.then((payload) => {
if (payload && payload.ok && payload.connectProxyPort != null) {
extensionState.connectProxyPort = payload.connectProxyPort;
extensionState.proxyPort = payload.proxyPort ?? null;
extensionState.virtualHosts = payload.virtualHosts || extensionState.virtualHosts;
applyPAC(getActiveTlds(extensionState.virtualHosts));
} else if (payload && payload.ok && attempt + 1 < maxAttempts) {
retryGetStateForConnectProxy(delaySeconds, attempt + 1);
}
})
.catch(() => {});
}, delayMs);
_retryTimers.push(timerId);
}
/**
* Schedule a reconnect attempt after the current backoff delay.
* Doubles the delay on each call, capped at MAX_RECONNECT_DELAY.
* No-op if a reconnect is already scheduled.
*/
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
}, reconnectDelay);
}
/**
* Open the native messaging port, fetch initial state, apply the PAC proxy,
* and wire up message and disconnect listeners.
* On disconnect: rejects all pending requests, clears the PAC, and schedules a reconnect.
*/
function connect() {
debugLog('connect: attempting connectNative', HOST_NAME);
try {
port = browser.runtime.connectNative(HOST_NAME);
log('Connected to native host');
debugLog('connect: connected, fetching getState then apply/clear PAC');
send({ type: 'getState', payload: {} })
.then((payload) => {
if (payload && payload.ok) {
extensionState.proxyPort = payload.proxyPort ?? null;
extensionState.connectProxyPort = payload.connectProxyPort ?? null;
extensionState.virtualHosts = payload.virtualHosts || [];
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
}
if (payload.settings && typeof payload.settings.notifyOnTunnelError === 'boolean') {
notifyOnTunnelError = payload.settings.notifyOnTunnelError;
}
applyPAC(getActiveTlds(extensionState.virtualHosts));
if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2);
} else {
applyPAC();
}
})
.catch(() => applyPAC());
} catch (e) {
log('connectNative failed:', e);
debugLog('connect: failed', e.message, e.stack);
scheduleReconnect();
return;
}
reconnectDelay = INITIAL_RECONNECT_DELAY;
port.onMessage.addListener((msg) => {
debugLog('onMessage: full msg=', JSON.stringify(msg));
debugLog('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('Connection established:', connId, 'peer:', peerInfo.publicKey?.slice(0, 8));
activeConnections.set(connId, {
connId,
swarmId,
peerKey: peerInfo.publicKey || '',
createdAt: Date.now()
});
updateExtensionState();
} else if (msg.event === 'error') {
const connId = payload.connId;
const errorMsg = payload.message || 'Unknown error';
log('Connection ERROR:', connId, errorMsg);
activeConnections.delete(connId);
updateExtensionState();
} else if (msg.event === 'end') {
const connId = payload.connId;
log('Connection ended:', connId);
activeConnections.delete(connId);
updateExtensionState();
}
}
if (msg.type === 'response' && msg.id != null) {
debugLog('onMessage: response id=', msg.id, 'payloadKeys=', msg.payload && Object.keys(msg.payload), 'payload=', msg.payload && JSON.stringify(msg.payload).slice(0, 300));
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);
}
} else {
debugLog('onMessage: response id=', msg.id, 'no pending handler');
}
return;
}
if (msg.type === 'event') {
const p = msg.payload || {};
const eventSwarmId = p.swarmId;
const isTunnelEvent = msg.event === 'tunnelReady' || msg.event === 'tunnelClosed' || msg.event === 'tunnelError';
if (isTunnelEvent) {
log('Broadcasting event to tabs:', msg.event, 'hostname:', p.hostname);
} else {
log('Broadcasting event to tabs:', msg.event, 'connId:', p.connId, 'swarmId:', eventSwarmId);
}
if (eventSwarmId != null) {
for (const [tabId, swarmIds] of tabSwarms) {
if (swarmIds.has(eventSwarmId)) {
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
}
}
}
if (isTunnelEvent) {
for (const tabId of subscribedTabs) {
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
}
if (msg.event === 'tunnelError' && notifyOnTunnelError && browser.notifications) {
const label = p.hostname || p.label || p.swarmId || 'tunnel';
const errMsg = p.error || p.message || 'Tunnel error';
browser.notifications.create('holesail-tunnel-error-' + Date.now(), {
type: 'basic',
title: 'Holesail — Tunnel Error',
message: label + ': ' + errMsg,
iconUrl: browser.runtime.getURL('icons/48.png'),
}).catch(() => {});
}
}
}
});
port.onDisconnect.addListener(() => {
debugLog('onDisconnect');
log('Native host disconnected');
extensionState.hostConnected = false;
activeConnections.clear();
updateExtensionState();
clearProxy();
port = null;
for (const t of _retryTimers) clearTimeout(t);
_retryTimers.length = 0;
if (notifyOnDisconnect && browser.notifications) {
browser.notifications.create('holesail-host-disconnect', {
type: 'basic',
title: 'Holesail Browser',
message: 'Native host disconnected.',
iconUrl: browser.runtime.getURL('icons/48.png'),
}).catch(() => {});
}
const err = new Error('Native host disconnected');
for (const [id, p] of pending) {
p.reject(err);
}
pending.clear();
for (const tabId of subscribedTabs) {
browser.tabs.sendMessage(tabId, { type: 'holesail-host-disconnect' }).catch(() => {});
}
scheduleReconnect();
});
}