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))
+ {
+
+

+
Scan with PolyPilot on iOS/Android to connect
+
+ }
+
+
+
+ }
+
+
+ }
+
@if (settings.Mode == ConnectionMode.Remote)
{
@@ -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;