Files
holesail-browser/extension/background/native-messaging.js
T
Raven Scott cbc663cacb
CI / Build & Test (push) Successful in 2m44s
fix(native-messaging): suppress "Native host has exited" console error
Read runtime.lastError in the onDisconnect callback so Chrome does not
log "Unchecked runtime.lastError: Native host has exited" to the
extensions console. The disconnect reason is forwarded to debugLog so
it remains visible when Debug Mode is enabled.
2026-03-01 02:42:06 -05:00

256 lines
9.5 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(() => {
// Reading lastError suppresses the "Unchecked runtime.lastError: Native host has exited"
// console error that Chrome logs when the host process exits unexpectedly.
const disconnectReason = browser.runtime.lastError?.message || null;
if (disconnectReason) {
debugLog('onDisconnect reason:', disconnectReason);
}
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();
});
}