aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-17 10:01:05 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-17 10:01:05 +0200
commit6329beaca34e31749ff0343d02e27db2826157e7 (patch)
tree68bc6afb731b976285ff30563cbfcedf347ecdb2 /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parent3bf5972dd418c23ecf98695fbf8c5f763a2109ef (diff)
downloadmeshbay-6329beaca34e31749ff0343d02e27db2826157e7.tar.gz
fix(spa): say when the device refuses the local network
Android 17 makes the local network a permission of the application, and Chrome 155 targets it: without it the node's LAN addresses are dropped and a group on the phone's own Wi-Fi failed with "DataChannel closed". The transport now names that refusal and the group page says what to allow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js89
1 files changed, 84 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 15c7b09a..0d987d9a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -103,6 +103,71 @@ function _retiredBundleError() {
return err;
}
+// ── A device that refuses the local network ──────────────────────────────────
+//
+// Android 17 makes reaching the local network a permission of the application
+// (ACCESS_LOCAL_NETWORK), enforced for any application that targets it. Chrome
+// does from 155, and holds the permission only once the person grants it. Until
+// then the browser drops the node's addresses on the phone's own subnet: they
+// are absent from getStats, no check goes out to them, the node's own checks
+// get no answer, ICE fails and the channel closes. "DataChannel closed" was all
+// this page could say, and on mobile data the same group opened, over the
+// node's public address. Measured on a Pixel, Android 17, Chrome 155,
+// 2026-10-16 (tests/test_local_network_refused.py).
+
+const _PRIVATE_V4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/;
+const _IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
+
+function _sdpCandidates(sdp) {
+ const out = [];
+ for (const line of String(sdp || '').split(/\r?\n/)) {
+ if (!line.startsWith('a=candidate:')) continue;
+ const parts = line.split(' ');
+ if (parts.length >= 8) out.push({ address: parts[4], type: parts[7] });
+ }
+ return out;
+}
+
+const _slash24 = (address) => address.split('.').slice(0, 3).join('.');
+
+/**
+ * Whether this device dropped the node's local addresses rather than failed to
+ * reach them.
+ *
+ * A browser pairs every remote address of a family with each local candidate
+ * of that family, and getStats lists each one that was paired, answered or
+ * not: desktop Chrome 152, 155 and 157 listed all six of a node's. So a private
+ * IPv4 address of the node that is missing, while we have IPv4 of our own, was
+ * refused here. A desktop browser hides its own addresses behind `.local`
+ * names, which are on a local network by construction. With nothing paired at
+ * all there is no comparison to make, and only an address on our own subnet
+ * says it.
+ */
+function localNetworkRefused(offerSdp, answerSdp, remoteAddresses) {
+ const paired = new Set(remoteAddresses);
+ const refused = _sdpCandidates(answerSdp)
+ .filter((c) => c.type === 'host' && _PRIVATE_V4.test(c.address) && !paired.has(c.address))
+ .map((c) => c.address);
+ if (!refused.length) return false;
+ const ours = _sdpCandidates(offerSdp);
+ const ourV4 = ours.filter((c) => _IPV4.test(c.address)).map((c) => c.address);
+ if (!ourV4.length && !ours.some((c) => c.address.endsWith('.local'))) return false;
+ if (paired.size > 0) return true;
+ return ourV4.some((a) => refused.some((r) => _slash24(r) === _slash24(a)));
+}
+
+async function _localNetworkRefusedBy(pc, offerSdp, answerSdp) {
+ try {
+ const remote = [];
+ (await pc.getStats()).forEach((r) => {
+ if (r.type === 'remote-candidate') remote.push(r.address || r.ip);
+ });
+ return localNetworkRefused(offerSdp, answerSdp, remote);
+ } catch {
+ return false; // a closed connection has no stats: the old error stands
+ }
+}
+
// Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a
// slow link and small enough that nothing accumulates.
// How long to collect ICE candidates before sending the offer anyway. Long
@@ -886,6 +951,9 @@ class MeshBayTransport {
// whatever is current — the `pc === this._pc` check below is what that
// buys.
const pc = this._pc;
+ // Whether a candidate pair ever answered on this connection: a failure
+ // after that is not the local network being refused.
+ let iceReached = false;
pc.onconnectionstatechange = () => {
console.log('[MeshBay] PC state:', pc.connectionState);
trace('pc_state', { state: pc.connectionState });
@@ -913,10 +981,9 @@ class MeshBayTransport {
// `connected`/`completed` is the first moment this browser knows the peer
// is there at all: a candidate pair answered. `checking` is not — it is
// this side trying addresses that may all be dead.
- if (pc === this._pc
- && (pc.iceConnectionState === 'connected'
- || pc.iceConnectionState === 'completed')) {
- this._noteConnectProgress('ice_connected');
+ if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
+ iceReached = true;
+ if (pc === this._pc) this._noteConnectProgress('ice_connected');
}
};
@@ -1029,9 +1096,20 @@ class MeshBayTransport {
const answer = await resp.json();
this._rawAnswerSdp = answer.sdp;
+ const offerSdp = this._pc.localDescription.sdp;
await this._pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp });
- await channelReady;
+ try {
+ await channelReady;
+ } catch (err) {
+ if (!iceReached && await _localNetworkRefusedBy(pc, offerSdp, answer.sdp)) {
+ trace('local_network_refused', {});
+ const refused = new Error('This device refused to reach the local network');
+ refused.reason = 'local_network_blocked';
+ throw refused;
+ }
+ throw err;
+ }
console.log('[MeshBay] DataChannel open, sending handshake for group', groupId,
'channel=', this._channel?.readyState,
'crypto=', !!window.MeshBayCrypto);
@@ -2581,4 +2659,5 @@ function extendTransport(part) {
}
// Export
+MeshBayTransport.localNetworkRefused = localNetworkRefused;
window.MeshBayTransport = MeshBayTransport;