Skip to content

Commit ad63b55

Browse files
committed
Improve LNA fix
1 parent e539856 commit ad63b55

2 files changed

Lines changed: 23 additions & 52 deletions

File tree

src/bridge/connection.ts

Lines changed: 22 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,57 +8,17 @@
88
*/
99

1010
import type { Protocol } from './utils';
11-
import { fetchWithTimeout, isLocalNetworkHost, resolveBaseUrl } from './utils';
12-
13-
/**
14-
* Pre-request Chrome's Local Network Access (LNA) permission for a host.
15-
*
16-
* When `https://web.eca.dev` fetches a private IP, Chrome gates the
17-
* request behind a user permission prompt. This function triggers that
18-
* prompt **once** before port scanning so that all subsequent probes
19-
* succeed without blocking on user interaction.
20-
*
21-
* For non-local hosts this is a no-op.
22-
*
23-
* @returns true if the host is non-local or the LNA permission was granted.
24-
*/
25-
export async function requestLocalNetworkAccess(
26-
host: string,
27-
protocol: Protocol = 'http',
28-
): Promise<boolean> {
29-
if (!isLocalNetworkHost(host)) return true;
30-
31-
try {
32-
// Fire a single throwaway fetch to trigger the LNA prompt.
33-
// We use port 7777 (first discovery port) — the server may or may
34-
// not be there, but the prompt still fires for the hostname.
35-
// 30s timeout: user needs time to read and click "Allow".
36-
await fetchWithTimeout(
37-
`${protocol}://${host}:7777/api/v1/health`,
38-
undefined,
39-
30_000,
40-
);
41-
return true;
42-
} catch {
43-
// Even if this fetch fails (e.g. nothing on port 7777), the LNA
44-
// permission may still have been granted for the origin — Chrome
45-
// remembers the grant regardless of the HTTP outcome.
46-
return true;
47-
}
48-
}
11+
import { fetchWithTimeout, isLocalNetworkHost, resolveBaseUrl, resolveProtocol } from './utils';
4912

5013
/**
5114
* Lightweight probe to check if an ECA server is listening on a given port.
5215
*
5316
* Used by auto-discovery to quickly scan a port range without requiring
54-
* full authentication.
17+
* full authentication. Uses `mode: 'no-cors'` so we only need to know
18+
* "is something responding?" — the opaque response is fine for discovery.
5519
*
5620
* Tries both HTTP and HTTPS in parallel to handle protocol mismatches
5721
* (e.g. user selected HTTPS but server runs HTTP, or vice-versa).
58-
*
59-
* NOTE: Call {@link requestLocalNetworkAccess} once before scanning
60-
* so that Chrome's LNA permission is already granted and these fast
61-
* probes aren't blocked by the permission prompt.
6222
*/
6323
export async function probePort(
6424
host: string,
@@ -71,12 +31,23 @@ export async function probePort(
7131
const results = await Promise.allSettled(
7232
protocols.map(async (proto) => {
7333
const url = `${proto}://${host}:${port}/api/v1/health`;
74-
await fetchWithTimeout(url, undefined, 3_000);
34+
await fetchWithTimeout(url, { mode: 'no-cors' }, 3_000);
7535
}),
7636
);
7737
return results.some((r) => r.status === 'fulfilled');
7838
}
7939

40+
/** True when the page is served over HTTPS and the target is plain HTTP on a private IP. */
41+
function isMixedContentScenario(host: string, protocol?: Protocol): boolean {
42+
return globalThis.location?.protocol === 'https:'
43+
&& resolveProtocol(host, protocol) === 'http'
44+
&& isLocalNetworkHost(host);
45+
}
46+
47+
const MIXED_CONTENT_HINT =
48+
'Your browser may be blocking this request (HTTPS → HTTP on a private network). '
49+
+ 'Check that you\'ve allowed Local Network Access for this site in your browser settings.';
50+
8051
/**
8152
* Test whether a host is reachable and the password is valid.
8253
*
@@ -87,6 +58,7 @@ export async function probePort(
8758
*/
8859
export async function testConnection(host: string, password: string, protocol?: Protocol): Promise<string | null> {
8960
const baseUrl = resolveBaseUrl(host, protocol);
61+
const mixedContent = isMixedContentScenario(host, protocol);
9062

9163
// 1. Test host reachability (health endpoint — no auth)
9264
try {
@@ -98,9 +70,13 @@ export async function testConnection(host: string, password: string, protocol?:
9870
}
9971
} catch (err: any) {
10072
if (err.name === 'AbortError') {
101-
return 'Connection timed out. Check the address and try again.';
73+
return mixedContent
74+
? `Connection timed out. ${MIXED_CONTENT_HINT}`
75+
: 'Connection timed out. Check the address and try again.';
10276
}
103-
return 'Could not reach host. Check the address and try again.';
77+
return mixedContent
78+
? `Could not reach host. ${MIXED_CONTENT_HINT}`
79+
: 'Could not reach host. Check the address and try again.';
10480
}
10581

10682
// 2. Test authentication (session endpoint — requires auth)

src/pages/RemoteProduct.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import { useCallback, useEffect, useRef, useState } from 'react';
14-
import { probePort, requestLocalNetworkAccess, testConnection } from '../bridge/connection';
14+
import { probePort, testConnection } from '../bridge/connection';
1515
import type { WebBridge } from '../bridge/transport';
1616
import type { ChatEntry, WorkspaceFolder } from '../bridge/types';
1717
import type { Protocol } from '../bridge/utils';
@@ -137,11 +137,6 @@ export function RemoteProduct() {
137137
const ports: number[] = [];
138138
for (let p = DISCOVERY_PORT_START; p <= DISCOVERY_PORT_END; p++) ports.push(p);
139139

140-
// On local networks, trigger Chrome's LNA permission prompt before
141-
// scanning so the user can grant access without racing the 3s probe timeouts.
142-
await requestLocalNetworkAccess(host, protocol);
143-
if (abort.signal.aborted) return;
144-
145140
const progress: DiscoveryProgress = { total: ports.length, checked: 0, found: [] };
146141
setDiscovery({ ...progress });
147142

0 commit comments

Comments
 (0)