diff --git a/PolyPilot.Tests/ConnectionSettingsTests.cs b/PolyPilot.Tests/ConnectionSettingsTests.cs index 0077eed0..df960aba 100644 --- a/PolyPilot.Tests/ConnectionSettingsTests.cs +++ b/PolyPilot.Tests/ConnectionSettingsTests.cs @@ -208,6 +208,62 @@ public void CliSourceMode_Enum_HasExpectedValues() Assert.Equal(1, (int)CliSourceMode.System); } + [Theory] + [InlineData(null, null)] + [InlineData("", "")] + [InlineData(" ", " ")] + public void NormalizeRemoteUrl_NullOrEmpty_ReturnsAsIs(string? input, string? expected) + { + Assert.Equal(expected, ConnectionSettings.NormalizeRemoteUrl(input)); + } + + [Theory] + [InlineData("http://192.168.1.5:4322", "http://192.168.1.5:4322")] + [InlineData("https://my-tunnel.devtunnels.ms", "https://my-tunnel.devtunnels.ms")] + [InlineData("ws://localhost:4322", "ws://localhost:4322")] + [InlineData("wss://tunnel.example.com", "wss://tunnel.example.com")] + [InlineData("HTTP://MYHOST:5000", "HTTP://MYHOST:5000")] + public void NormalizeRemoteUrl_WithScheme_PassesThrough(string input, string expected) + { + Assert.Equal(expected, ConnectionSettings.NormalizeRemoteUrl(input)); + } + + [Theory] + [InlineData("192.168.1.5:4322", "http://192.168.1.5:4322")] + [InlineData("localhost:4322", "http://localhost:4322")] + [InlineData("10.0.0.1", "http://10.0.0.1")] + [InlineData("myserver.local:8080", "http://myserver.local:8080")] + public void NormalizeRemoteUrl_BareAddress_PrependsHttp(string input, string expected) + { + Assert.Equal(expected, ConnectionSettings.NormalizeRemoteUrl(input)); + } + + [Theory] + [InlineData("xxx.devtunnels.ms", "https://xxx.devtunnels.ms")] + [InlineData("abc123.ngrok.io", "https://abc123.ngrok.io")] + [InlineData("tunnel.ngrok-free.app", "https://tunnel.ngrok-free.app")] + [InlineData("my.cloudflare.com", "https://my.cloudflare.com")] + public void NormalizeRemoteUrl_KnownTlsHost_PrependsHttps(string input, string expected) + { + Assert.Equal(expected, ConnectionSettings.NormalizeRemoteUrl(input)); + } + + [Theory] + [InlineData("http://192.168.1.5:4322/", "http://192.168.1.5:4322")] + [InlineData(" 192.168.1.5:4322 ", "http://192.168.1.5:4322")] + [InlineData("https://tunnel.devtunnels.ms/", "https://tunnel.devtunnels.ms")] + public void NormalizeRemoteUrl_TrimsWhitespaceAndSlash(string input, string expected) + { + Assert.Equal(expected, ConnectionSettings.NormalizeRemoteUrl(input)); + } + + [Fact] + public void NormalizeRemoteUrl_DoesNotDoubleScheme() + { + var result = ConnectionSettings.NormalizeRemoteUrl("http://http://example.com"); + Assert.Equal("http://http://example.com", result); + } + private void Dispose() { try { Directory.Delete(_testDir, true); } catch { } diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 7e4a08df..4134045b 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -591,7 +591,7 @@ { var connSettings = ConnectionSettings.Load(); connSettings.Mode = ConnectionMode.Remote; - connSettings.RemoteUrl = mobileRemoteUrl; + connSettings.RemoteUrl = ConnectionSettings.NormalizeRemoteUrl(mobileRemoteUrl) ?? mobileRemoteUrl; connSettings.RemoteToken = mobileRemoteToken; connSettings.Save(); await CopilotService.ReconnectAsync(connSettings); diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index c4f49982..9b4fa739 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -1087,6 +1087,10 @@ return; } + // Normalize URL before saving (adds http:// if no scheme) + if (settings.Mode == ConnectionMode.Remote) + settings.RemoteUrl = ConnectionSettings.NormalizeRemoteUrl(settings.RemoteUrl); + settings.Save(); ShowStatus("Settings saved. Reconnecting...", "", 0); diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index bb8b46aa..9f75d36a 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -60,6 +60,35 @@ public class ConnectionSettings public List DisabledPlugins { get; set; } = new(); public bool EnableSessionNotifications { get; set; } = false; + /// + /// Normalizes a remote URL by ensuring it has an http(s):// scheme. + /// Plain IPs/hostnames get http://, devtunnels/known TLS hosts get https://. + /// Already-schemed URLs pass through unchanged. Returns null for null/empty input. + /// + public static string? NormalizeRemoteUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + var trimmed = url.Trim().TrimEnd('/'); + + // Already has a recognized scheme — return as-is + if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("ws://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("wss://", StringComparison.OrdinalIgnoreCase)) + return trimmed; + + // Heuristic: devtunnels and well-known cloud hosts use TLS + if (trimmed.Contains(".devtunnels.ms", StringComparison.OrdinalIgnoreCase) + || trimmed.Contains(".ngrok", StringComparison.OrdinalIgnoreCase) + || trimmed.Contains(".cloudflare", StringComparison.OrdinalIgnoreCase)) + return "https://" + trimmed; + + // Everything else (bare IP, localhost, LAN hostname) → http + return "http://" + trimmed; + } + [JsonIgnore] public string CliUrl => Mode == ConnectionMode.Remote && !string.IsNullOrEmpty(RemoteUrl) ? RemoteUrl diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 157f367a..d394b7ad 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -12,7 +12,8 @@ public partial class CopilotService /// private async Task InitializeRemoteAsync(ConnectionSettings settings, CancellationToken ct) { - var wsUrl = settings.RemoteUrl!.TrimEnd('/'); + var normalized = ConnectionSettings.NormalizeRemoteUrl(settings.RemoteUrl) ?? settings.RemoteUrl!; + var wsUrl = normalized.TrimEnd('/'); if (wsUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) wsUrl = "wss://" + wsUrl[8..]; else if (wsUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) @@ -20,8 +21,6 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati else if (wsUrl.StartsWith("wss://", StringComparison.OrdinalIgnoreCase) || wsUrl.StartsWith("ws://", StringComparison.OrdinalIgnoreCase)) { /* already a WebSocket URL */ } - else - wsUrl = "wss://" + wsUrl; Debug($"Remote mode: connecting to {wsUrl}");