CI / Build & Test (push) Successful in 2m44s
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.
256 lines
9.5 KiB
JavaScript
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();
|
|
});
|
|
}
|