Skip to content

Commit a80bb13

Browse files
committed
Improve user message for firefox and safari
1 parent ca5d5a3 commit a80bb13

File tree

6 files changed

+132
-15
lines changed

6 files changed

+132
-15
lines changed

src/bridge/connection.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
* SSE connection.
88
*/
99

10-
import type { Protocol } from './utils';
11-
import { fetchWithTimeout, isLocalNetworkHost, resolveBaseUrl, resolveProtocol } from './utils';
10+
import type { BrowserKind, Protocol } from './utils';
11+
import { detectBrowser, fetchWithTimeout, isLocalNetworkHost, resolveBaseUrl, resolveProtocol } from './utils';
1212

1313
/**
1414
* Lightweight probe to check if an ECA server is listening on a given port.
@@ -38,15 +38,63 @@ export async function probePort(
3838
}
3939

4040
/** 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 {
41+
export function isMixedContentScenario(host: string, protocol?: Protocol): boolean {
4242
return globalThis.location?.protocol === 'https:'
4343
&& resolveProtocol(host, protocol) === 'http'
4444
&& isLocalNetworkHost(host);
4545
}
4646

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.';
47+
/**
48+
* Return a browser-specific explanation for mixed-content failures.
49+
*
50+
* Chrome's `targetAddressSpace` triggers its own Local Network Access
51+
* prompt, so it gets a short nudge. Firefox and Safari have **no**
52+
* programmatic escape hatch — the only realistic options are switching
53+
* to a Chromium-based browser or loading the page over plain HTTP.
54+
*/
55+
function mixedContentHintFor(browser: BrowserKind): string {
56+
switch (browser) {
57+
case 'chrome':
58+
return 'Allow the Local Network Access prompt in your browser, then retry.';
59+
case 'firefox':
60+
return 'Firefox blocks HTTPS pages from connecting to private HTTP servers. '
61+
+ 'Use a Chromium-based browser (Chrome, Edge, Brave) or access this page over HTTP instead.';
62+
case 'safari':
63+
return 'Safari blocks HTTPS pages from connecting to private HTTP servers. '
64+
+ 'Use a Chromium-based browser (Chrome, Edge, Brave) or access this page over HTTP instead.';
65+
default:
66+
return 'Your browser may be blocking this request (HTTPS → HTTP on a private network). '
67+
+ 'Try using a Chromium-based browser (Chrome, Edge, Brave) or access this page over HTTP instead.';
68+
}
69+
}
70+
71+
/**
72+
* Proactive mixed-content warning for the ConnectForm.
73+
*
74+
* Returns a user-facing hint string when the host/protocol combination
75+
* will trigger mixed-content blocking, or `null` when no warning is
76+
* needed (page served over HTTP, target is public, or Chrome which
77+
* handles it via the LNA prompt automatically).
78+
*/
79+
export function getMixedContentWarning(host: string, protocol?: Protocol): string | null {
80+
if (!isMixedContentScenario(host, protocol)) return null;
81+
const browser = detectBrowser();
82+
// Chrome handles this via targetAddressSpace + LNA prompt — no warning needed
83+
if (browser === 'chrome') return null;
84+
return mixedContentHintFor(browser);
85+
}
86+
87+
/**
88+
* Check whether a connection error is likely caused by mixed-content
89+
* blocking and return a helpful hint, or `null` if unrelated.
90+
*
91+
* Used by RemoteSession to decorate post-connect errors (e.g. Safari's
92+
* `TypeError` when the SSE fetch is silently blocked).
93+
*/
94+
export function getMixedContentErrorHint(host: string, protocol?: Protocol): string | null {
95+
if (!isMixedContentScenario(host, protocol)) return null;
96+
return mixedContentHintFor(detectBrowser());
97+
}
5098

5199
/**
52100
* Test whether a host is reachable and the password is valid.
@@ -58,7 +106,7 @@ const MIXED_CONTENT_HINT =
58106
*/
59107
export async function testConnection(host: string, password: string, protocol?: Protocol): Promise<string | null> {
60108
const baseUrl = resolveBaseUrl(host, protocol);
61-
const mixedContent = isMixedContentScenario(host, protocol);
109+
const mixedContentHint = getMixedContentErrorHint(host, protocol);
62110

63111
// 1. Test host reachability (health endpoint — no auth)
64112
try {
@@ -70,12 +118,12 @@ export async function testConnection(host: string, password: string, protocol?:
70118
}
71119
} catch (err: any) {
72120
if (err.name === 'AbortError') {
73-
return mixedContent
74-
? `Connection timed out. ${MIXED_CONTENT_HINT}`
121+
return mixedContentHint
122+
? `Connection timed out. ${mixedContentHint}`
75123
: 'Connection timed out. Check the address and try again.';
76124
}
77-
return mixedContent
78-
? `Could not reach host. ${MIXED_CONTENT_HINT}`
125+
return mixedContentHint
126+
? `Could not reach host. ${mixedContentHint}`
79127
: 'Could not reach host. Check the address and try again.';
80128
}
81129

src/bridge/utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@
77
*/
88

99
export type Protocol = 'http' | 'https';
10+
export type BrowserKind = 'chrome' | 'firefox' | 'safari' | 'other';
11+
12+
/**
13+
* Detect the current browser from the user-agent string.
14+
*
15+
* We only care about Chrome, Firefox and Safari because each handles
16+
* mixed-content / Local Network Access differently:
17+
* - Chrome supports `targetAddressSpace` and shows an LNA prompt
18+
* - Firefox and Safari block HTTPS→HTTP silently with no API escape hatch
19+
*
20+
* Order matters: Chrome's UA also contains "Safari", and many browsers
21+
* (Edge, Opera, Brave) contain "Chrome", which is fine — they all
22+
* inherit Chrome's LNA behaviour.
23+
*/
24+
export function detectBrowser(): BrowserKind {
25+
const ua = navigator.userAgent;
26+
// All Chromium-based browsers (Chrome, Edge, Brave, Opera, Arc…)
27+
// include "Chrome/" in their UA and inherit LNA support.
28+
if (/Chrome\//.test(ua)) return 'chrome';
29+
if (/Firefox\//.test(ua)) return 'firefox';
30+
// Real Safari has "Safari/" but NOT "Chrome/"
31+
if (/Safari\//.test(ua)) return 'safari';
32+
return 'other';
33+
}
1034

1135
/** RFC 1918 private addresses (192.168.x, 10.x, 172.16-31.x). */
1236
const PRIVATE_NETWORK_RE =

src/pages/ConnectForm.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,29 @@
415415
cursor: not-allowed;
416416
}
417417

418+
/* ---- Mixed-content warning banner ---- */
419+
420+
.mixed-content-warning {
421+
display: flex;
422+
align-items: flex-start;
423+
gap: 0.55rem;
424+
margin-top: 0.25rem;
425+
padding: 0.6rem 0.85rem;
426+
background: rgba(218, 165, 32, 0.08);
427+
border: 1px solid rgba(218, 165, 32, 0.22);
428+
border-radius: 0.6rem;
429+
color: #d4a017;
430+
font-size: 0.78rem;
431+
line-height: 1.55;
432+
text-align: left;
433+
}
434+
435+
.mixed-content-warning-icon {
436+
flex-shrink: 0;
437+
font-size: 0.9rem;
438+
line-height: 1.35;
439+
}
440+
418441
/* ---- Error banner ---- */
419442

420443
.connect-error {

src/pages/ConnectForm.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useEffect, useRef, useState } from 'react';
1+
import { useEffect, useMemo, useRef, useState } from 'react';
2+
import { getMixedContentWarning } from '../bridge/connection';
23
import type { Protocol } from '../bridge/utils';
34
import { CodeRain } from '../components/CodeRain';
45
import './ConnectForm.css';
@@ -41,6 +42,12 @@ export function ConnectForm({ onConnect, onDiscover, error, isConnecting, discov
4142
setProtocol(isPrivate ? 'http' : 'https');
4243
}, [host]);
4344

45+
// Proactive warning for Firefox/Safari when HTTPS→HTTP to private IP
46+
const mixedContentWarning = useMemo(
47+
() => getMixedContentWarning(host.trim(), protocol),
48+
[host, protocol],
49+
);
50+
4451
const handleSubmit = (e: React.FormEvent) => {
4552
e.preventDefault();
4653
const trimmedHost = host.trim();
@@ -144,6 +151,13 @@ export function ConnectForm({ onConnect, onDiscover, error, isConnecting, discov
144151
</div>
145152
)}
146153

154+
{mixedContentWarning && (
155+
<div className="mixed-content-warning">
156+
<span className="mixed-content-warning-icon"></span>
157+
<span>{mixedContentWarning}</span>
158+
</div>
159+
)}
160+
147161
<button
148162
type="submit"
149163
className="connect-button"

src/pages/RemoteProduct.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import { useCallback, useEffect, useRef, useState } from 'react';
1414
import { EcaRemoteApi } from '../bridge/api';
15-
import { probePort, testConnection } from '../bridge/connection';
15+
import { getMixedContentErrorHint, probePort, testConnection } from '../bridge/connection';
1616
import type { WebBridge } from '../bridge/transport';
1717
import type { ChatEntry, WorkspaceFolder } from '../bridge/types';
1818
import type { Protocol } from '../bridge/utils';
@@ -187,7 +187,9 @@ export function RemoteProduct() {
187187

188188
// Only error when zero servers were discovered
189189
if (progress.found.length === 0) {
190-
setFormError('No ECA servers found on ports 7777–7796. Check the host and password.');
190+
const mixedHint = getMixedContentErrorHint(host, protocol);
191+
const base = 'No ECA servers found on ports 7777–7796.';
192+
setFormError(mixedHint ? `${base} ${mixedHint}` : `${base} Check the host and password.`);
191193
setFormConnecting(false);
192194
return;
193195
}

src/pages/RemoteSession.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Component, useCallback, useEffect, useRef, useState } from 'react';
22
import type { ErrorInfo, ReactNode } from 'react';
3+
import { getMixedContentErrorHint } from '../bridge/connection';
34
import { WebBridge } from '../bridge/transport';
45
import type { ReconnectionState } from '../bridge/types';
56
import type { Protocol } from '../bridge/utils';
@@ -124,7 +125,12 @@ export function RemoteSession({ host, password, protocol, lastChatId, onStatusCh
124125
bridge.disconnect();
125126
if (!mountedRef.current) return;
126127
bridgeRef.current = null;
127-
const message = err.message || 'Connection failed';
128+
let message = err.message || 'Connection failed';
129+
// Decorate generic errors (e.g. Safari's "TypeError") with mixed-content guidance
130+
const mixedHint = getMixedContentErrorHint(host, protocol);
131+
if (mixedHint) {
132+
message = `${message}. ${mixedHint}`;
133+
}
128134
setState({ status: 'error', message });
129135
onStatusChangeRef.current('error', message);
130136
onBridgeChangeRef.current?.(null);

0 commit comments

Comments
 (0)