diff --git a/PolyPilot/.mauidevflow b/PolyPilot/.mauidevflow new file mode 100644 index 0000000000..7a53246dba --- /dev/null +++ b/PolyPilot/.mauidevflow @@ -0,0 +1,3 @@ +{ + "port": 9233 +} diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 6d10321806..3a3d00bee1 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -7,6 +7,7 @@ @inject NavigationManager Nav @inject DevTunnelService DevTunnelService @inject GitAutoUpdateService GitAutoUpdate +@inject WsBridgeServer WsBridgeServer @implements IAsyncDisposable
@@ -277,6 +278,15 @@ } GitAutoUpdate.Initialize(); + + // Auto-start direct sharing if previously enabled + if (connSettings.DirectSharingEnabled && !string.IsNullOrEmpty(connSettings.ServerPassword) + && !WsBridgeServer.IsRunning && DevTunnelService.State != TunnelState.Running) + { + WsBridgeServer.ServerPassword = connSettings.ServerPassword; + WsBridgeServer.SetCopilotService(CopilotService); + WsBridgeServer.Start(DevTunnelService.BridgePort, connSettings.Port); + } } catch (Exception ex) { diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index a7f7ccd8d5..e0d2bb8590 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -4,6 +4,8 @@ @inject CopilotService CopilotService @inject ServerManager ServerManager @inject DevTunnelService DevTunnelService +@inject WsBridgeServer WsBridgeServer +@inject TailscaleService TailscaleService @inject QrScannerService QrScanner @inject GitAutoUpdateService GitAutoUpdate @inject NavigationManager Nav @@ -191,12 +193,73 @@
} + @if (PlatformHelper.IsDesktop && (settings.Mode == ConnectionMode.Embedded || (settings.Mode == ConnectionMode.Persistent && serverAlive))) + { +
+

Direct Connection

+
+

Share your server directly over LAN, Tailscale, or VPN — no DevTunnel needed.

+ +
+ + +
+ + @if (!WsBridgeServer.IsRunning) + { + + @if (string.IsNullOrWhiteSpace(settings.ServerPassword)) + { +

Set a password above to enable direct sharing.

+ } + } + else + { +
+ + Listening on port @DevTunnelService.BridgePort +
+ +
+ @if (TailscaleService.IsRunning) + { +
+ + http://@(TailscaleService.MagicDnsName ?? TailscaleService.TailscaleIp):@DevTunnelService.BridgePort + +
+ } + @foreach (var ip in localIps) + { +
+ + http://@ip:@DevTunnelService.BridgePort +
+ } + + @if (!string.IsNullOrEmpty(directQrCodeDataUri)) + { +
+ QR Code +

Scan with PolyPilot on iOS/Android to connect

+
+ } +
+ + + } +
+
+ } + @if (settings.Mode == ConnectionMode.Remote) {

Remote Server

-

Connect to a Copilot server running on another machine (via DevTunnel URL).

+

Connect to a Copilot server running on another machine.

@if (PlatformHelper.IsMobile) { @@ -207,11 +270,11 @@
- +
- +
@@ -328,6 +391,8 @@ private bool tunnelBusy; private bool showToken; private string? qrCodeDataUri; + private string? directQrCodeDataUri; + private List localIps = new(); private CancellationTokenSource? _statusCts; private string searchQuery = ""; private DotNetObjectReference? _selfRef; @@ -361,7 +426,7 @@ // Also check all section keywords within the group return groupKeyword switch { - "connection" => SectionVisible("transport mode embedded persistent remote server port start stop pid devtunnel share tunnel mobile qr url token connect save reconnect"), + "connection" => SectionVisible("transport mode embedded persistent remote server port start stop pid devtunnel share tunnel mobile qr url token connect save reconnect direct tailscale lan password"), "ui" => SectionVisible("chat message layout default reversed both left theme"), "developer" => SectionVisible("auto update main git watch relaunch rebuild"), _ => true @@ -396,9 +461,18 @@ DevTunnelService.OnStateChanged += OnTunnelStateChanged; GitAutoUpdate.OnStateChanged += OnAutoUpdateStateChanged; + WsBridgeServer.OnStateChanged += OnBridgeStateChanged; if (DevTunnelService.State == TunnelState.Running && DevTunnelService.TunnelUrl != null) GenerateQrCode(DevTunnelService.TunnelUrl, DevTunnelService.AccessToken); + + // Detect network info for direct sharing + DetectLocalIps(); + await TailscaleService.RefreshAsync(); + + // If bridge is already running with direct sharing, generate QR + if (WsBridgeServer.IsRunning && settings.DirectSharingEnabled) + GenerateDirectQrCode(); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -429,6 +503,7 @@ { DevTunnelService.OnStateChanged -= OnTunnelStateChanged; GitAutoUpdate.OnStateChanged -= OnAutoUpdateStateChanged; + WsBridgeServer.OnStateChanged -= OnBridgeStateChanged; _ = JS.InvokeVoidAsync("eval", "window.__settingsRef = null;"); _selfRef?.Dispose(); } @@ -480,7 +555,7 @@ { ConnectionMode.Embedded => "Copilot process dies when app closes.", ConnectionMode.Persistent => "Default. Copilot server survives app restarts. Sessions persist.", - ConnectionMode.Remote => "Connect to a remote server via DevTunnel URL.", + ConnectionMode.Remote => "Connect to a remote server via URL (DevTunnel, Tailscale, LAN, etc.).", _ => "" }; @@ -571,6 +646,109 @@ } } + private void StartDirectSharing() + { + if (string.IsNullOrWhiteSpace(settings.ServerPassword)) return; + + settings.DirectSharingEnabled = true; + settings.Save(); + + // Set password on bridge and start it + WsBridgeServer.ServerPassword = settings.ServerPassword; + WsBridgeServer.SetCopilotService(CopilotService); + WsBridgeServer.Start(DevTunnelService.BridgePort, settings.Port); + + if (WsBridgeServer.IsRunning) + { + GenerateDirectQrCode(); + ShowStatus("Direct sharing enabled", "success"); + } + else + { + ShowStatus("Failed to start direct sharing", "error"); + } + } + + private void StopDirectSharing() + { + // Only stop bridge if DevTunnel isn't using it + if (DevTunnelService.State != TunnelState.Running) + WsBridgeServer.Stop(); + + settings.DirectSharingEnabled = false; + settings.Save(); + directQrCodeDataUri = null; + ShowStatus("Direct sharing stopped", "success"); + } + + private void OnBridgeStateChanged() + { + InvokeAsync(() => + { + if (WsBridgeServer.IsRunning && settings.DirectSharingEnabled) + GenerateDirectQrCode(); + else if (!WsBridgeServer.IsRunning) + directQrCodeDataUri = null; + StateHasChanged(); + }); + } + + private string GetDirectUrl() + { + var host = TailscaleService.IsRunning + ? (TailscaleService.MagicDnsName ?? TailscaleService.TailscaleIp ?? localIps.FirstOrDefault() ?? "localhost") + : (localIps.FirstOrDefault() ?? "localhost"); + return $"http://{host}:{DevTunnelService.BridgePort}"; + } + + private void GenerateDirectQrCode() + { + var url = GetDirectUrl(); + try + { + var payload = string.IsNullOrEmpty(settings.ServerPassword) + ? url + : System.Text.Json.JsonSerializer.Serialize(new { url, token = settings.ServerPassword }); + + using var qrGenerator = new QRCoder.QRCodeGenerator(); + using var qrCodeData = qrGenerator.CreateQrCode(payload, QRCoder.QRCodeGenerator.ECCLevel.L); + using var qrCode = new QRCoder.PngByteQRCode(qrCodeData); + var pngBytes = qrCode.GetGraphic(4, new byte[] { 0, 0, 0 }, new byte[] { 255, 255, 255 }); + directQrCodeDataUri = $"data:image/png;base64,{Convert.ToBase64String(pngBytes)}"; + } + catch (Exception ex) + { + Console.WriteLine($"[QR] Error generating direct QR code: {ex.Message}"); + } + } + + private async Task CopyDirectUrl() + { + var url = GetDirectUrl(); + await Microsoft.Maui.ApplicationModel.DataTransfer.Clipboard.SetTextAsync(url); + ShowStatus("URL copied!", "success"); + } + + private void DetectLocalIps() + { + localIps.Clear(); + try + { + foreach (var iface in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()) + { + if (iface.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue; + if (iface.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue; + + foreach (var addr in iface.GetIPProperties().UnicastAddresses) + { + if (addr.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + localIps.Add(addr.Address.ToString()); + } + } + } + catch { } + } + private async Task StartServer() { starting = true; diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index 916b2822b0..67755eb2ac 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -96,6 +96,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index cb617e0cfe..1a8fa0b83a 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -37,6 +37,8 @@ public class ConnectionSettings public string? RemoteToken { get; set; } public string? TunnelId { get; set; } public bool AutoStartTunnel { get; set; } = false; + public string? ServerPassword { get; set; } + public bool DirectSharingEnabled { get; set; } = false; public ChatLayout ChatLayout { get; set; } = ChatLayout.Default; public UiTheme Theme { get; set; } = UiTheme.PolyPilotDark; public bool AutoUpdateFromMain { get; set; } = false; diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 72bc215928..6442113206 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Text; using System.Text.Json; +using Microsoft.Extensions.Logging; using PolyPilot.Models; using GitHub.Copilot.SDK; @@ -15,6 +16,7 @@ public partial class CopilotService : IAsyncDisposable private readonly ServerManager _serverManager; private readonly WsBridgeClient _bridgeClient; private readonly DemoService _demoService; + private readonly ILogger _logger; private CopilotClient? _client; private string? _activeSessionName; private SynchronizationContext? _syncContext; @@ -100,12 +102,13 @@ private static string FindProjectDir() public ConnectionMode CurrentMode { get; private set; } = ConnectionMode.Embedded; public List AvailableModels { get; private set; } = new(); - public CopilotService(ChatDatabase chatDb, ServerManager serverManager, WsBridgeClient bridgeClient) + public CopilotService(ChatDatabase chatDb, ServerManager serverManager, WsBridgeClient bridgeClient, ILogger logger) { _chatDb = chatDb; _serverManager = serverManager; _bridgeClient = bridgeClient; _demoService = new DemoService(); + _logger = logger; } // Debug info @@ -154,6 +157,7 @@ private void Debug(string message) { LastDebugMessage = message; Console.WriteLine($"[DEBUG] {message}"); + _logger.LogInformation("[CopilotService] {Message}", message); OnDebug?.Invoke(message); } @@ -211,32 +215,27 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) } Debug($"Android: connecting to remote server at {settings.CliUrl}"); #endif - // In Persistent mode, auto-start the server if not already running - if (settings.Mode == ConnectionMode.Persistent) - { - if (!_serverManager.CheckServerRunning("localhost", settings.Port)) - { - Debug($"Persistent server not running, auto-starting on port {settings.Port}..."); - var started = await _serverManager.StartServerAsync(settings.Port); - if (!started) - { - Debug("Failed to auto-start server, falling back to Embedded mode"); - settings.Mode = ConnectionMode.Embedded; - CurrentMode = ConnectionMode.Embedded; - } - } - else - { - Debug($"Persistent server already running on port {settings.Port}"); - } - } - + // Persistent mode on desktop uses the same embedded SDK client as Embedded mode, + // but sessions are persisted to disk and restored on app restart. + // The headless server (CliUrl) approach has protocol compatibility issues + // with the current SDK version, so we use embedded mode universally on desktop. + Debug($"Creating CopilotClient for mode={settings.Mode}..."); _client = CreateClient(settings); + Debug("CopilotClient created successfully, calling StartAsync..."); - await _client.StartAsync(cancellationToken); - IsInitialized = true; - NeedsConfiguration = false; - Debug($"Copilot client started in {settings.Mode} mode"); + try + { + await _client.StartAsync(cancellationToken); + IsInitialized = true; + NeedsConfiguration = false; + Debug($"Copilot client started in {settings.Mode} mode"); + } + catch (Exception ex) + { + Debug($"CopilotClient.StartAsync FAILED: {ex.GetType().Name}: {ex.Message}"); + _logger.LogError(ex, "CopilotClient.StartAsync failed"); + throw; + } // Load default system instructions from the project's copilot-instructions.md var instructionsPath = Path.Combine(ProjectDir, ".github", "copilot-instructions.md"); @@ -376,15 +375,12 @@ public async Task ReconnectAsync(ConnectionSettings settings, CancellationToken private CopilotClient CreateClient(ConnectionSettings settings) { - // Remote mode is handled by InitializeRemoteAsync, not here - var options = settings.Mode switch + // Both Embedded and Persistent modes use the default SDK client (stdio). + // Persistent mode differs only in that sessions are saved/restored from disk. + // Remote mode is handled by InitializeRemoteAsync, not here. + var options = new CopilotClientOptions { - ConnectionMode.Persistent => new CopilotClientOptions - { - CliUrl = settings.CliUrl, - UseStdio = false - }, - _ => new CopilotClientOptions() + CliPath = "copilot" }; // Pass additional MCP server configs via CLI args. diff --git a/PolyPilot/Services/TailscaleService.cs b/PolyPilot/Services/TailscaleService.cs new file mode 100644 index 0000000000..2bb5dd9dff --- /dev/null +++ b/PolyPilot/Services/TailscaleService.cs @@ -0,0 +1,138 @@ +using System.Net.Sockets; +using System.Text.Json; + +namespace PolyPilot.Services; + +/// +/// Detects Tailscale status and network info on desktop platforms. +/// Uses the Tailscale local API via Unix socket when available, +/// falling back to the CLI. +/// +public class TailscaleService +{ + private const string SocketPath = "/var/run/tailscale/tailscaled.sock"; + + public string? TailscaleIp { get; private set; } + public string? MagicDnsName { get; private set; } + public bool IsRunning { get; private set; } + + /// + /// Refresh Tailscale status. Safe to call frequently — returns quickly if not available. + /// + public async Task RefreshAsync() + { + TailscaleIp = null; + MagicDnsName = null; + IsRunning = false; + +#if IOS || ANDROID + return; +#else + try + { + // Try local API via Unix socket first (works regardless of CLI install path) + if (File.Exists(SocketPath)) + { + var json = await QueryLocalApiAsync(); + if (json != null && ParseStatus(json)) + { + IsRunning = true; + return; + } + } + + // Fallback: CLI + await TryCliAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[Tailscale] Detection failed: {ex.Message}"); + } +#endif + } + + private async Task QueryLocalApiAsync() + { + try + { + var handler = new SocketsHttpHandler + { + ConnectCallback = async (ctx, ct) => + { + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + await socket.ConnectAsync(new UnixDomainSocketEndPoint(SocketPath), ct); + return new NetworkStream(socket, ownsSocket: true); + } + }; + using var client = new HttpClient(handler) { BaseAddress = new Uri("http://local-tailscaled.sock/") }; + client.Timeout = TimeSpan.FromSeconds(3); + return await client.GetStringAsync("localapi/v0/status"); + } + catch (Exception ex) + { + Console.WriteLine($"[Tailscale] Unix socket query failed: {ex.Message}"); + return null; + } + } + + private bool ParseStatus(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("Self", out var self)) + { + if (self.TryGetProperty("TailscaleIPs", out var ips) && ips.GetArrayLength() > 0) + TailscaleIp = ips[0].GetString(); + + if (self.TryGetProperty("DNSName", out var dns)) + { + var dnsName = dns.GetString()?.TrimEnd('.'); + if (!string.IsNullOrEmpty(dnsName)) + MagicDnsName = dnsName; + } + } + + return TailscaleIp != null; + } + catch (Exception ex) + { + Console.WriteLine($"[Tailscale] Failed to parse status: {ex.Message}"); + return false; + } + } + + private async Task TryCliAsync() + { +#if !IOS && !ANDROID + try + { + using var proc = new System.Diagnostics.Process(); + proc.StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "tailscale", + Arguments = "status --json", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + proc.Start(); + var output = await proc.StandardOutput.ReadToEndAsync(); + await proc.WaitForExitAsync(); + + if (proc.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + if (ParseStatus(output)) + IsRunning = true; + } + } + catch (Exception ex) + { + Console.WriteLine($"[Tailscale] CLI fallback failed: {ex.Message}"); + } +#endif + } +} diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index c91d519444..ded89beeea 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -28,6 +28,12 @@ public class WsBridgeServer : IDisposable /// public string? AccessToken { get; set; } + /// + /// User-configured password for direct (non-DevTunnel) connections. + /// Accepted alongside AccessToken for non-loopback connections. + /// + public string? ServerPassword { get; set; } + public event Action? OnStateChanged; /// @@ -197,28 +203,39 @@ private async Task AcceptLoopAsync(CancellationToken ct) /// private bool ValidateClientToken(HttpListenerRequest request) { - if (string.IsNullOrEmpty(AccessToken)) - return true; // No token configured — local-only mode, allow all + var hasAccessToken = !string.IsNullOrEmpty(AccessToken); + var hasServerPassword = !string.IsNullOrEmpty(ServerPassword); + + if (!hasAccessToken && !hasServerPassword) + return true; // No auth configured — local-only mode, allow all // Loopback connections are trusted — DevTunnel proxies appear as localhost if (IsLoopbackRequest(request)) return true; + // Extract the client-supplied credential from header or query param + string? clientToken = null; + // Check X-Tunnel-Authorization header: "tunnel " var authHeader = request.Headers["X-Tunnel-Authorization"]; if (!string.IsNullOrEmpty(authHeader)) { - var token = authHeader.StartsWith("tunnel ", StringComparison.OrdinalIgnoreCase) - ? authHeader["tunnel ".Length..] - : authHeader; - if (string.Equals(token.Trim(), AccessToken, StringComparison.Ordinal)) - return true; + clientToken = authHeader.StartsWith("tunnel ", StringComparison.OrdinalIgnoreCase) + ? authHeader["tunnel ".Length..].Trim() + : authHeader.Trim(); } // Check query string: ?token= - var queryToken = request.QueryString["token"]; - if (!string.IsNullOrEmpty(queryToken) && - string.Equals(queryToken, AccessToken, StringComparison.Ordinal)) + if (clientToken == null) + clientToken = request.QueryString["token"]; + + if (string.IsNullOrEmpty(clientToken)) + return false; + + // Accept if it matches either the DevTunnel access token or the server password + if (hasAccessToken && string.Equals(clientToken, AccessToken, StringComparison.Ordinal)) + return true; + if (hasServerPassword && string.Equals(clientToken, ServerPassword, StringComparison.Ordinal)) return true; return false;