From 95231b054ab7e5fb180adbfcf547a0eaf7377fd0 Mon Sep 17 00:00:00 2001 From: Ieuan Walker Date: Mon, 11 May 2026 20:04:01 +0100 Subject: [PATCH 1/2] Add interactive Copilot authentication to splash screen Introduce AuthFeature for Copilot CLI login, supporting both GitHub.com and Enterprise device code flows. Enhance splash screen UI for account selection, device code entry, and sign-in status. Inject AuthFeature and CopilotClientFeature into MainPage and App, updating initialization to require authentication before loading sessions. Add logging for authentication and initialization steps. --- src/Cockpit/App.xaml.cs | 16 +- src/Cockpit/Features/Auth/AuthFeature.cs | 154 ++++++++++++ src/Cockpit/MainPage.xaml | 174 +++++++++++++- src/Cockpit/MainPage.xaml.cs | 289 ++++++++++++++++++++++- src/Cockpit/MauiProgram.cs | 2 + 5 files changed, 616 insertions(+), 19 deletions(-) create mode 100644 src/Cockpit/Features/Auth/AuthFeature.cs diff --git a/src/Cockpit/App.xaml.cs b/src/Cockpit/App.xaml.cs index 36037508..d72361f8 100644 --- a/src/Cockpit/App.xaml.cs +++ b/src/Cockpit/App.xaml.cs @@ -1,7 +1,10 @@ using Cockpit.Controls; +using Cockpit.Features.Auth; +using Cockpit.Features.Sdk; using Cockpit.Features.Sessions; using Cockpit.Features.Splash; using Cockpit.Features.Theme; +using Microsoft.Extensions.Logging; namespace Cockpit; @@ -12,22 +15,31 @@ public partial class App : Application readonly SplashFeature _splashFeature; readonly SessionFeature _sessionFeature; readonly ThemeStateFeature _themeStateFeature; + readonly CopilotClientFeature _copilotClientFeature; + readonly AuthFeature _authFeature; + readonly ILogger _mainPageLogger; public App( SplashFeature splashFeature, SessionFeature sessionFeature, - ThemeStateFeature themeStateFeature) + ThemeStateFeature themeStateFeature, + CopilotClientFeature copilotClientFeature, + AuthFeature authFeature, + ILogger mainPageLogger) { InitializeComponent(); _splashFeature = splashFeature; _sessionFeature = sessionFeature; _themeStateFeature = themeStateFeature; + _copilotClientFeature = copilotClientFeature; + _authFeature = authFeature; + _mainPageLogger = mainPageLogger; } protected override Window CreateWindow(IActivationState? activationState) { - _mainWindow = new Window(new MainPage(_splashFeature, _sessionFeature, _themeStateFeature)) + _mainWindow = new Window(new MainPage(_splashFeature, _sessionFeature, _themeStateFeature, _copilotClientFeature, _authFeature, _mainPageLogger)) { Title = "Cockpit", TitleBar = new TitleBar diff --git a/src/Cockpit/Features/Auth/AuthFeature.cs b/src/Cockpit/Features/Auth/AuthFeature.cs new file mode 100644 index 00000000..4700c7ab --- /dev/null +++ b/src/Cockpit/Features/Auth/AuthFeature.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Cockpit.Features.Auth; + +/// +/// Handles the GitHub Copilot CLI login flow (OAuth device flow). +/// Supports both github.com and GitHub Enterprise hosts. +/// +public partial class AuthFeature +{ + readonly ILogger _logger; + + public record DeviceFlowInfo(string Url, string Code); + + public AuthFeature(ILogger logger) + { + _logger = logger; + } + + /// + /// Spawns copilot login and monitors stdout/stderr for the device flow URL and code. + /// Pass for enterprise accounts (e.g. https://example.ghe.com), + /// or null for github.com. + /// + public async Task RunLoginAsync( + string? host = null, + Action? onDeviceFlow = null, + CancellationToken cancellationToken = default) + { + List args = ["login"]; + if(!string.IsNullOrWhiteSpace(host) && !host.Equals("https://github.com", StringComparison.OrdinalIgnoreCase)) + { + args.Add("--host"); + args.Add(host); + } + + _logger.LogInformation("Starting copilot login with args: {Args}", string.Join(" ", args)); + + ProcessStartInfo psi = new("copilot") + { + Arguments = string.Join(" ", args), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using Process? process = Process.Start(psi); + if(process is null) + { + _logger.LogError("Failed to start copilot login process"); + return false; + } + + string? deviceUrl = null; + string? deviceCode = null; + bool deviceFlowSent = false; + + void ParseOutput(string data) + { + Match urlMatch = DeviceUrlRegex().Match(data); + Match codeMatch = DeviceCodeRegex().Match(data); + + if(urlMatch.Success) + { + deviceUrl = urlMatch.Groups[1].Value; + } + + if(codeMatch.Success) + { + deviceCode = codeMatch.Groups[1].Value; + } + + if(deviceUrl is not null && deviceCode is not null && !deviceFlowSent) + { + deviceFlowSent = true; + _logger.LogInformation("Device flow received - URL: {Url}, Code: {Code}", deviceUrl, deviceCode); + onDeviceFlow?.Invoke(new DeviceFlowInfo(deviceUrl, deviceCode)); + } + } + + process.OutputDataReceived += (_, e) => + { + if(e.Data is not null) + { + ParseOutput(e.Data); + } + }; + + process.ErrorDataReceived += (_, e) => + { + if(e.Data is not null) + { + ParseOutput(e.Data); + } + }; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(cancellationToken); + + bool success = process.ExitCode == 0; + _logger.LogInformation("copilot login exited with code {ExitCode}", process.ExitCode); + return success; + } + + /// + /// Reads the last-used enterprise host from ~/.copilot/config.json for pre-filling the UI. + /// Returns null if not found or on any error. + /// + public static string? ReadHostFromConfig() + { + try + { + string configPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".copilot", "config.json"); + + if(!File.Exists(configPath)) + { + return null; + } + + string json = File.ReadAllText(configPath); + using JsonDocument doc = JsonDocument.Parse(json, new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip + }); + + if(doc.RootElement.TryGetProperty("lastLoggedInUser", out JsonElement user) && + user.TryGetProperty("host", out JsonElement host)) + { + return host.GetString(); + } + } + catch + { + // Fall through to default (github.com) + } + + return null; + } + + // Matches any host's device login URL (github.com or enterprise) + [GeneratedRegex(@"(https://\S+/login/device)")] + private static partial Regex DeviceUrlRegex(); + + [GeneratedRegex(@"code\s+([A-Z0-9]{4}-[A-Z0-9]{4})")] + private static partial Regex DeviceCodeRegex(); +} diff --git a/src/Cockpit/MainPage.xaml b/src/Cockpit/MainPage.xaml index e284ae9c..43e43053 100644 --- a/src/Cockpit/MainPage.xaml +++ b/src/Cockpit/MainPage.xaml @@ -17,7 +17,8 @@ ZIndex="1000"> + Spacing="0" + WidthRequest="420"> - + + + + + + + + + + + + + + + + +