Fix iOS WebSocket hang with self-signed TLS certificates

iOS Safari silently rejects wss:// connections to servers using
self-signed certificates — the trust exception accepted for the page
does not extend to WebSocket handshakes. This causes the UI to hang
on "Establishing WebSocket connection..." indefinitely.

- Make --certificate and --key optional; server runs plain HTTP when omitted
- When TLS is enabled, also start a plain HTTP listener on --http-port
  (default 8081) so iOS clients can connect via ws:// instead of wss://
- Add a 5-second connection timeout on the frontend that detects the hang
  and shows a clickable link to the HTTP fallback URL

https://claude.ai/code/session_01VJ4CsBALnYcVhFJpY5cD5k
This commit is contained in:
Claude 2026-02-09 09:42:51 +00:00
parent bd3a5e2af0
commit c617a371cf
No known key found for this signature in database
2 changed files with 92 additions and 17 deletions

View file

@ -50,11 +50,26 @@ struct Args {
#[arg(short, long, conflicts_with = "verbose")]
quiet: bool,
/// TLS certificate file (PEM format)
///
/// When provided along with --key, the server will use HTTPS/WSS.
/// A plain HTTP listener is also started on --http-port for clients
/// that cannot validate the certificate (e.g. iOS with self-signed certs).
/// If omitted, the server runs over plain HTTP only.
#[arg(short, long)]
certificate: PathBuf,
certificate: Option<PathBuf>,
/// TLS private key file (PEM format)
#[arg(short, long)]
key: PathBuf,
key: Option<PathBuf>,
/// Port for the plain HTTP listener (used alongside HTTPS)
///
/// When TLS is enabled, a second HTTP-only listener is started on this
/// port so that iOS devices (which reject self-signed WSS certs) can
/// connect via ws:// instead.
#[arg(long, default_value_t = 8081, value_name = "PORT")]
http_port: u16,
}
/// Messages sent between peers via the signaling server
@ -199,6 +214,8 @@ async fn main() {
.parse()
.expect("Invalid host:port combination");
let use_tls = args.certificate.is_some() && args.key.is_some();
println!();
println!(" ╭─────────────────────────────────────────╮");
println!("\x1b[1;33mLAN Share\x1b[0m │");
@ -206,10 +223,25 @@ async fn main() {
println!(" ╰─────────────────────────────────────────╯");
println!();
println!(" \x1b[1mServer running at:\x1b[0m");
println!(" → http://{}", addr);
if args.host == "0.0.0.0" {
if let Ok(local_ip) = local_ip_address::local_ip() {
println!(" → http://{}:{}", local_ip, args.port);
if use_tls {
println!(" → https://{}", addr);
let http_addr: SocketAddr = format!("{}:{}", args.host, args.http_port)
.parse()
.expect("Invalid host:http_port combination");
println!(" → http://{} \x1b[90m(plain HTTP for iOS)\x1b[0m", http_addr);
if args.host == "0.0.0.0" {
if let Ok(local_ip) = local_ip_address::local_ip() {
println!(" → https://{}:{}", local_ip, args.port);
println!(" → http://{}:{} \x1b[90m(plain HTTP for iOS)\x1b[0m", local_ip, args.http_port);
}
}
} else {
println!(" → http://{}", addr);
if args.host == "0.0.0.0" {
if let Ok(local_ip) = local_ip_address::local_ip() {
println!(" → http://{}:{}", local_ip, args.port);
}
}
}
println!();
@ -219,13 +251,36 @@ async fn main() {
info!("Server starting on {}", addr);
if use_tls {
let cert = args.certificate.unwrap();
let key = args.key.unwrap();
let config = RustlsConfig::from_pem_file(cert, key).await.unwrap();
let config = RustlsConfig::from_pem_file(args.certificate, args.key).await.unwrap();
// Start a plain HTTP listener alongside HTTPS so that iOS devices
// (which silently reject self-signed WSS certificates) can connect
// via ws:// instead.
let http_addr: SocketAddr = format!("{}:{}", args.host, args.http_port)
.parse()
.expect("Invalid host:http_port combination");
let http_app = app.clone();
info!("Plain HTTP listener on {}", http_addr);
tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(http_addr).await.unwrap();
axum::serve(listener, http_app.into_make_service_with_connect_info::<SocketAddr>())
.await
.unwrap();
});
axum_server::bind_rustls(addr, config)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.unwrap();
axum_server::bind_rustls(addr, config)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.unwrap();
} else {
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.await
.unwrap();
}
}
async fn serve_index() -> impl IntoResponse {