CI / Build & Test (push) Successful in 4m27s
- Native host: add autopass + corestore deps, sync-manager (createInvite, pairWithInvite, push/pull) - Apply synced state via same flow as backup restore (state.json only, no certs) - Holesail-manager: setOnStateSaved hook for sync push - Extension: Sync dashboard page, getSyncStatus/createSyncInvite/pairWithInvite, syncApplied event - Docs: NATIVE-HOST.md sync commands, SYNC.md
259 lines
9.6 KiB
JavaScript
259 lines
9.6 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((err) => { log('getState failed:', err.message); 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(() => {});
|
|
}
|
|
}
|
|
if (msg.event === 'syncApplied' && typeof dashboardTabs !== 'undefined') {
|
|
for (const tabId of dashboardTabs) {
|
|
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
port.onDisconnect.addListener((p) => {
|
|
const reason = p?.error?.message || browser.runtime.lastError?.message || null;
|
|
if (reason) {
|
|
log('onDisconnect reason:', reason);
|
|
}
|
|
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();
|
|
});
|
|
}
|