Files
holesail-browser/extension/background/native-messaging.js
T
Raven Scott f1e98a7edd
CI / Build & Test (push) Successful in 2m54s
docs: add CONTRIBUTING.md, CHANGELOG.md, and JSDoc to entire codebase
Add docs/CONTRIBUTING.md covering the build system, dev workflow, all
npm scripts, how to add new native host message types, code style, and
debugging guidance.

Add CHANGELOG.md at the project root documenting all features and fixes
across the 1.0.0 release.

Add JSDoc (@param, @returns) to all previously undocumented exported
functions across 35 JS files:
- native-host/holesail-manager/ (index, virtual-hosts, service-tunnels,
  servers, port-allocator)
- native-host top-level managers (startup, connect-proxy, https-proxy,
  certificate-authority, ssh-manager, rdp-manager)
- extension/background/ (logs, native-messaging, proxy, message-router)
- extension/dashboard/core/ (utils, navigation, init)
- extension/dashboard/ui/ (modal, toast, state-tag)
- extension/dashboard/pages/ (all 10 page files)
- extension/dashboard/refresh.js, events.js
- extension/dashboard/data/hostname-validator.js
- scripts/ (build-host, run-install)
2026-03-01 00:40:53 -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));
log('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));
log('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();
});
}