/** * 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} 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(); }); }