From 88f3ac29da956dff464fb1677cac010c330f9232 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 14 Apr 2026 10:02:25 -0400 Subject: [PATCH 1/3] Fix Copilot release signing on macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index faa5aab..4de6c40 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -305,22 +305,30 @@ jobs: APP_PATH=$(find ./artifacts/macos -name "*.app" -type d | head -1) if [ -n "$APP_PATH" ]; then echo "Re-signing with hardened runtime: $APP_PATH" - # Sign helper executables under MonoBundle/runtimes explicitly; --deep - # does not reliably recurse into that layout. + # Sign nested code first, then sign the app bundle last. + # Avoid --deep on the final app sign: it overwrites the Copilot CLI's + # Node/V8 entitlements and causes "Failed to reserve virtual memory + # for CodeRange" crashes in release builds. if [ -d "$APP_PATH/Contents/MonoBundle/runtimes" ]; then find "$APP_PATH/Contents/MonoBundle/runtimes" \( -name "copilot" -o -name "unxip" \) -type f | while read -r item; do chmod +x "$item" - codesign --force --options runtime --timestamp \ - --sign "$APPLE_CODESIGN_IDENTITY" "$item" + if [ "$(basename "$item")" = "copilot" ]; then + codesign --force --options runtime --timestamp \ + --preserve-metadata=entitlements \ + --sign "$APPLE_CODESIGN_IDENTITY" "$item" + else + codesign --force --options runtime --timestamp \ + --sign "$APPLE_CODESIGN_IDENTITY" "$item" + fi done fi # Sign all nested frameworks and dylibs first - find "$APP_PATH" \( -name "*.dylib" -o -name "*.framework" \) | while read -r item; do + find "$APP_PATH/Contents" \( -name "*.dylib" -o -name "*.framework" \) | while read -r item; do codesign --force --options runtime --timestamp \ --sign "$APPLE_CODESIGN_IDENTITY" "$item" done # Sign the app bundle - codesign --force --deep --options runtime --timestamp \ + codesign --force --options runtime --timestamp \ --entitlements src/MauiSherpa.MacOS/Entitlements.plist \ --sign "$APPLE_CODESIGN_IDENTITY" \ "$APP_PATH" @@ -342,6 +350,19 @@ jobs: echo "::error::Hardened Runtime is NOT enabled — notarization will fail" exit 1 fi + + COPILOT_PATH="$APP_PATH/Contents/MonoBundle/runtimes/osx-arm64/native/copilot" + if [ -f "$COPILOT_PATH" ]; then + codesign -d --entitlements :- "$COPILOT_PATH" 2>&1 | tee /tmp/copilot-entitlements.txt + if grep -q "com.apple.security.cs.allow-jit" /tmp/copilot-entitlements.txt \ + && grep -q "com.apple.security.cs.allow-unsigned-executable-memory" /tmp/copilot-entitlements.txt \ + && grep -q "com.apple.security.cs.disable-library-validation" /tmp/copilot-entitlements.txt; then + echo "✅ Copilot helper entitlements are preserved" + else + echo "::error::Copilot helper entitlements are missing — the CLI will crash in release builds" + exit 1 + fi + fi fi - name: Create ZIP archive From 806226751f74a54107fe72cef828eaa56e3fa5d9 Mon Sep 17 00:00:00 2001 From: Redth Date: Wed, 15 Apr 2026 17:42:49 -0400 Subject: [PATCH 2/3] Fix Copilot release availability checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotService.cs | 450 +++++++++++++----- src/MauiSherpa/Pages/Copilot.razor | 17 +- .../Services/CopilotServiceTests.cs | 45 ++ 3 files changed, 396 insertions(+), 116 deletions(-) create mode 100644 tests/MauiSherpa.Core.Tests/Services/CopilotServiceTests.cs diff --git a/src/MauiSherpa.Core/Services/CopilotService.cs b/src/MauiSherpa.Core/Services/CopilotService.cs index f9a707d..7a9f98a 100644 --- a/src/MauiSherpa.Core/Services/CopilotService.cs +++ b/src/MauiSherpa.Core/Services/CopilotService.cs @@ -20,6 +20,8 @@ private sealed record ResolvedPermissionRequest( private readonly ILoggingService _logger; private readonly ICopilotToolsService _toolsService; private readonly string _skillsPath; + private readonly string _copilotWorkingDirectory; + private readonly SemaphoreSlim _clientGate = new(1, 1); private readonly List _messages = new(); private readonly Dictionary _toolCallIdToName = new(); // Track callId -> toolName mapping @@ -54,7 +56,9 @@ public CopilotService(ILoggingService logger, ICopilotToolsService toolsService) _logger = logger; _toolsService = toolsService; _skillsPath = GetSkillsPath(); + _copilotWorkingDirectory = GetCopilotWorkingDirectory(); _logger.LogInformation($"Copilot skills path: {_skillsPath}"); + _logger.LogInformation($"Copilot working directory: {_copilotWorkingDirectory}"); } private static string GetSkillsPath() @@ -98,33 +102,207 @@ private static string GetSkillsPath() return baseDir; } + private static string GetCopilotWorkingDirectory() + { + var path = Path.Combine(AppDataPath.GetAppDataDirectory(), "copilot"); + Directory.CreateDirectory(path); + return path; + } + + private static string GetUserHomeDirectory() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return string.IsNullOrWhiteSpace(home) ? Environment.CurrentDirectory : home; + } + + internal static string BuildLaunchPath(string? currentPath = null, IEnumerable? extraDirectories = null) + { + var seen = new HashSet(StringComparer.Ordinal); + var orderedPaths = new List(); + + static string NormalizeDirectory(string path) + { + try + { + return Path.GetFullPath(path.Trim()); + } + catch + { + return path.Trim(); + } + } + + void AddDirectory(string? candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + return; + + var normalized = NormalizeDirectory(candidate); + if (!Directory.Exists(normalized) || !seen.Add(normalized)) + return; + + orderedPaths.Add(normalized); + } + + foreach (var dir in (currentPath ?? Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + AddDirectory(dir); + } + + if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst() || OperatingSystem.IsLinux()) + { + var home = GetUserHomeDirectory(); + + AddDirectory("/opt/homebrew/bin"); + AddDirectory("/usr/local/bin"); + AddDirectory("/opt/local/bin"); + AddDirectory("/usr/bin"); + AddDirectory("/bin"); + AddDirectory("/usr/sbin"); + AddDirectory("/sbin"); + + AddDirectory(Path.Combine(home, ".local", "bin")); + AddDirectory(Path.Combine(home, ".npm-global", "bin")); + AddDirectory(Path.Combine(home, ".yarn", "bin")); + AddDirectory(Path.Combine(home, ".volta", "bin")); + AddDirectory(Path.Combine(home, ".asdf", "shims")); + AddDirectory(Path.Combine(home, ".local", "share", "fnm", "aliases", "default", "bin")); + AddDirectory(Path.Combine(home, ".fnm", "aliases", "default", "bin")); + AddDirectory(Path.Combine(home, "bin")); + } + + if (extraDirectories != null) + { + foreach (var dir in extraDirectories) + AddDirectory(dir); + } + + return string.Join(Path.PathSeparator, orderedPaths); + } + + private static Dictionary BuildCliEnvironment(string? cliPath = null) + { + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase); + var home = GetUserHomeDirectory(); + + if (!string.IsNullOrWhiteSpace(home)) + { + environment["HOME"] = home; + environment["USERPROFILE"] = home; + + if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst() || OperatingSystem.IsLinux()) + { + var xdgConfigHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (string.IsNullOrWhiteSpace(xdgConfigHome)) + xdgConfigHome = Path.Combine(home, ".config"); + + environment["XDG_CONFIG_HOME"] = xdgConfigHome; + + var ghConfigDir = Environment.GetEnvironmentVariable("GH_CONFIG_DIR"); + if (string.IsNullOrWhiteSpace(ghConfigDir)) + ghConfigDir = Path.Combine(xdgConfigHome, "gh"); + + environment["GH_CONFIG_DIR"] = ghConfigDir; + } + } + + var extraDirs = !string.IsNullOrWhiteSpace(cliPath) + ? new[] { Path.GetDirectoryName(cliPath)! } + : Array.Empty(); + + var launchPath = BuildLaunchPath(extraDirectories: extraDirs); + if (!string.IsNullOrWhiteSpace(launchPath)) + environment["PATH"] = launchPath; + + foreach (var variable in new[] { "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "SHELL", "USER", "LOGNAME" }) + { + var value = Environment.GetEnvironmentVariable(variable); + if (!string.IsNullOrWhiteSpace(value)) + environment[variable] = value; + } + + return environment; + } + /// /// Resolves the Copilot CLI binary path. Checks the bundled runtimes path first, - /// then falls back to finding 'copilot' on the system PATH. + /// then common install locations for GUI-launched apps, and finally the system PATH. /// - private static string? ResolveCopilotCliPath() + internal static string? ResolveCopilotCliPath(string? pathEnv = null, string? baseDirectory = null, IEnumerable? additionalSearchPaths = null) { var rid = System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier; var binaryName = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( System.Runtime.InteropServices.OSPlatform.Windows) ? "copilot.exe" : "copilot"; - // Check bundled path (runtimes/{rid}/native/copilot) - var bundledPath = Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", binaryName); - if (File.Exists(bundledPath)) - return bundledPath; + var candidateBaseDirs = new[] + { + baseDirectory ?? AppContext.BaseDirectory, + Path.GetFullPath(Path.Combine(baseDirectory ?? AppContext.BaseDirectory, "..")), + Path.GetFullPath(Path.Combine(baseDirectory ?? AppContext.BaseDirectory, "..", "MonoBundle")) + } + .Distinct(StringComparer.Ordinal) + .ToArray(); + + foreach (var root in candidateBaseDirs) + { + var bundledPath = Path.Combine(root, "runtimes", rid, "native", binaryName); + if (File.Exists(bundledPath)) + return bundledPath; + + if (rid.StartsWith("maccatalyst-", StringComparison.OrdinalIgnoreCase)) + { + var osxRid = rid.Replace("maccatalyst-", "osx-"); + var osxPath = Path.Combine(root, "runtimes", osxRid, "native", binaryName); + if (File.Exists(osxPath)) + return osxPath; + } + } + + foreach (var candidate in additionalSearchPaths ?? Enumerable.Empty()) + { + if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate)) + return candidate; + } + + if (OperatingSystem.IsWindows()) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - // On Mac Catalyst, also check osx-arm64/osx-x64 in case the RID mapping wasn't applied - if (rid.StartsWith("maccatalyst-", StringComparison.OrdinalIgnoreCase)) + foreach (var candidate in new[] + { + Path.Combine(programFiles, "GitHub Copilot", binaryName), + Path.Combine(localAppData, "Microsoft", "WinGet", "Links", binaryName) + }) + { + if (File.Exists(candidate)) + return candidate; + } + } + else { - var osxRid = rid.Replace("maccatalyst-", "osx-"); - var osxPath = Path.Combine(AppContext.BaseDirectory, "runtimes", osxRid, "native", binaryName); - if (File.Exists(osxPath)) - return osxPath; + var home = GetUserHomeDirectory(); + foreach (var candidate in new[] + { + "/opt/homebrew/bin/copilot", + "/usr/local/bin/copilot", + "/opt/local/bin/copilot", + Path.Combine(home, ".local", "bin", "copilot"), + Path.Combine(home, ".npm-global", "bin", "copilot"), + Path.Combine(home, ".yarn", "bin", "copilot"), + Path.Combine(home, ".volta", "bin", "copilot"), + Path.Combine(home, ".asdf", "shims", "copilot"), + Path.Combine(home, "bin", "copilot") + }) + { + if (File.Exists(candidate)) + return candidate; + } } - // Fallback: find on system PATH - var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - var pathDirs = pathEnv.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + var pathDirs = BuildLaunchPath(pathEnv) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var dir in pathDirs) { var candidate = Path.Combine(dir, binaryName); @@ -144,132 +322,173 @@ public async Task CheckAvailabilityAsync(bool forceRefresh return _cachedAvailability; } - CopilotClient? tempClient = null; - var tempClientStarted = false; + await _clientGate.WaitAsync(); try { - _logger.LogInformation("Checking Copilot availability via SDK..."); - - var cliPath = ResolveCopilotCliPath(); - if (cliPath != null) - _logger.LogInformation($"Resolved Copilot CLI path: {cliPath}"); - - // Create a temporary client to check status - var options = new CopilotClientOptions + if (!forceRefresh && _cachedAvailability != null) { - AutoStart = true, - CliPath = cliPath - }; - - tempClient = new CopilotClient(options); - await tempClient.StartAsync(); - tempClientStarted = true; - - // Get version/status info - var statusResponse = await tempClient.GetStatusAsync(); - var version = statusResponse?.Version; - _logger.LogInformation($"Copilot CLI version: {version}"); - - // Check authentication status using SDK - var authResponse = await tempClient.GetAuthStatusAsync(); - - if (authResponse == null || !authResponse.IsAuthenticated) + _logger.LogInformation("Returning cached Copilot availability after synchronization"); + return _cachedAvailability; + } + + CopilotClient? tempClient = null; + var tempClientStarted = false; + try { - var statusMsg = authResponse?.StatusMessage ?? "Not logged in to GitHub Copilot"; - _logger.LogWarning($"Copilot not authenticated: {statusMsg}"); + _logger.LogInformation("Checking Copilot availability via SDK..."); + + var cliPath = ResolveCopilotCliPath(); + var cliEnvironment = BuildCliEnvironment(cliPath); + + if (cliPath != null) + _logger.LogInformation($"Resolved Copilot CLI path: {cliPath}"); + else + _logger.LogWarning("Could not resolve Copilot CLI path from bundle, well-known locations, or PATH"); + + var options = new CopilotClientOptions + { + AutoStart = true, + CliPath = cliPath, + Cwd = _copilotWorkingDirectory, + Environment = cliEnvironment + }; + + tempClient = new CopilotClient(options); + await tempClient.StartAsync(); + tempClientStarted = true; + + var statusResponse = await tempClient.GetStatusAsync(); + var version = statusResponse?.Version; + _logger.LogInformation($"Copilot CLI version: {version}"); + + var authResponse = await tempClient.GetAuthStatusAsync(); + + if (authResponse == null || !authResponse.IsAuthenticated) + { + var statusMsg = authResponse?.StatusMessage ?? "Not logged in to GitHub Copilot"; + _logger.LogWarning($"Copilot not authenticated: {statusMsg}"); + _cachedAvailability = new CopilotAvailability( + IsInstalled: true, + IsAuthenticated: false, + Version: version, + Login: authResponse?.Login, + ErrorMessage: statusMsg + ); + return _cachedAvailability; + } + + _logger.LogInformation($"Copilot authenticated as {authResponse.Login}"); + + if (_client == null) + { + _client = tempClient; + tempClient = null; + tempClientStarted = false; + _logger.LogInformation("Reusing availability-check Copilot client for the next session"); + } + _cachedAvailability = new CopilotAvailability( IsInstalled: true, - IsAuthenticated: false, + IsAuthenticated: true, Version: version, - Login: authResponse?.Login, - ErrorMessage: statusMsg + Login: authResponse.Login, + ErrorMessage: null ); return _cachedAvailability; } + catch (Exception ex) + { + _logger.LogError($"Error checking Copilot availability: {ex.Message}", ex); - _logger.LogInformation($"Copilot authenticated as {authResponse.Login}"); + var isNotInstalled = ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("No such file", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("cannot find", StringComparison.OrdinalIgnoreCase) || + ex is System.ComponentModel.Win32Exception; - if (_client == null) - { - _client = tempClient; - tempClient = null; - tempClientStarted = false; - _logger.LogInformation("Reusing availability-check Copilot client for the next session"); + _cachedAvailability = new CopilotAvailability( + IsInstalled: !isNotInstalled, + IsAuthenticated: false, + Version: null, + Login: null, + ErrorMessage: isNotInstalled + ? "GitHub Copilot CLI is not installed" + : ex.Message + ); + return _cachedAvailability; } - - _cachedAvailability = new CopilotAvailability( - IsInstalled: true, - IsAuthenticated: true, - Version: version, - Login: authResponse.Login, - ErrorMessage: null - ); - return _cachedAvailability; - } - catch (Exception ex) - { - _logger.LogError($"Error checking Copilot availability: {ex.Message}", ex); - - // If we can't start the client, assume CLI is not installed - var isNotInstalled = ex.Message.Contains("not found") || - ex.Message.Contains("No such file") || - ex.Message.Contains("cannot find") || - ex is System.ComponentModel.Win32Exception; - - _cachedAvailability = new CopilotAvailability( - IsInstalled: !isNotInstalled, - IsAuthenticated: false, - Version: null, - Login: null, - ErrorMessage: isNotInstalled - ? "GitHub Copilot CLI is not installed" - : ex.Message - ); - return _cachedAvailability; - } - finally - { - if (tempClient != null) + finally { - if (tempClientStarted) + if (tempClient != null) { - try - { - await tempClient.StopAsync(); - } - catch (Exception stopEx) + if (tempClientStarted) { - _logger.LogWarning($"Failed to stop temporary Copilot client cleanly: {stopEx.Message}"); - try { - await tempClient.ForceStopAsync(); + await tempClient.StopAsync(); } - catch (Exception forceStopEx) + catch (Exception stopEx) { - _logger.LogWarning($"Failed to force-stop temporary Copilot client: {forceStopEx.Message}"); + _logger.LogWarning($"Failed to stop temporary Copilot client cleanly: {stopEx.Message}"); + + try + { + await tempClient.ForceStopAsync(); + } + catch (Exception forceStopEx) + { + _logger.LogWarning($"Failed to force-stop temporary Copilot client: {forceStopEx.Message}"); + } } } - } - await tempClient.DisposeAsync(); + await tempClient.DisposeAsync(); + } } } + finally + { + _clientGate.Release(); + } } public async Task ConnectAsync() { - if (_client != null) - { - _logger.LogWarning("Already connected to Copilot"); - return; - } - + await _clientGate.WaitAsync(); try { + if (_client?.State == ConnectionState.Connected) + { + _logger.LogInformation("Already connected to Copilot"); + return; + } + + if (_client != null) + { + _logger.LogWarning("Discarding stale Copilot client before reconnecting"); + + try + { + await _client.StopAsync(); + } + catch + { + try + { + await _client.ForceStopAsync(); + } + catch + { + } + } + + await _client.DisposeAsync(); + _client = null; + } + _logger.LogInformation("Connecting to Copilot CLI..."); - + var cliPath = ResolveCopilotCliPath(); + var cliEnvironment = BuildCliEnvironment(cliPath); if (cliPath != null) _logger.LogInformation($"Resolved Copilot CLI path: {cliPath}"); @@ -277,14 +496,19 @@ public async Task ConnectAsync() { AutoStart = true, UseStdio = true, - Cwd = _skillsPath, // Set working directory to skills folder + Cwd = _copilotWorkingDirectory, LogLevel = "info", - CliPath = cliPath + CliPath = cliPath, + Environment = cliEnvironment }; _client = new CopilotClient(options); await _client.StartAsync(); - + + _cachedAvailability = _cachedAvailability is null + ? new CopilotAvailability(true, true, null, null, null) + : _cachedAvailability with { IsInstalled = true, IsAuthenticated = true, ErrorMessage = null }; + _logger.LogInformation("Connected to Copilot CLI"); } catch (Exception ex) @@ -293,6 +517,10 @@ public async Task ConnectAsync() _client = null; throw; } + finally + { + _clientGate.Release(); + } } public async Task DisconnectAsync() diff --git a/src/MauiSherpa/Pages/Copilot.razor b/src/MauiSherpa/Pages/Copilot.razor index 3f1ac04..eef5014 100644 --- a/src/MauiSherpa/Pages/Copilot.razor +++ b/src/MauiSherpa/Pages/Copilot.razor @@ -89,7 +89,7 @@ else if (availability != null && !availability.IsInstalled) - @@ -136,7 +136,7 @@ else if (availability != null && !availability.IsAuthenticated) - @@ -2371,10 +2371,10 @@ else _copilotDotNetRef?.Dispose(); } - private async Task CheckAvailability() + private async Task CheckAvailability(bool forceRefresh = false) { // Use cached availability if available for instant display - if (CopilotService.CachedAvailability != null) + if (!forceRefresh && CopilotService.CachedAvailability != null) { availability = CopilotService.CachedAvailability; isCheckingAvailability = false; @@ -2400,7 +2400,7 @@ else try { - availability = await CopilotService.CheckAvailabilityAsync(); + availability = await CopilotService.CheckAvailabilityAsync(forceRefresh); // Auto-connect if Copilot is available and authenticated if (availability.IsInstalled && availability.IsAuthenticated && CopilotService.CurrentSessionId == null) @@ -2439,6 +2439,9 @@ else private async Task Connect() { + if (isConnecting || CopilotService.IsConnected) + return; + isConnecting = true; StateHasChanged(); @@ -2446,10 +2449,14 @@ else { await CopilotService.ConnectAsync(); await CopilotService.StartSessionAsync(systemPrompt: _systemPrompt); + availability = availability is null + ? new CopilotAvailability(true, true, null, null, null) + : availability with { IsInstalled = true, IsAuthenticated = true, ErrorMessage = null }; } catch (Exception ex) { _logger.LogError($"Connection failed: {ex.Message}", ex); + availability = await CopilotService.CheckAvailabilityAsync(forceRefresh: true); try { await AlertService.ShowAlertAsync("Connection Failed", ex.Message); diff --git a/tests/MauiSherpa.Core.Tests/Services/CopilotServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/CopilotServiceTests.cs new file mode 100644 index 0000000..2a766e7 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/CopilotServiceTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Tests.Services; + +public class CopilotServiceTests +{ + [Fact] + public void BuildLaunchPath_RemovesDuplicates_WhileKeepingExistingEntries() + { + var basePath = string.Join(Path.PathSeparator, ["/usr/bin", "/bin", "/usr/bin"]); + + var launchPath = CopilotService.BuildLaunchPath(basePath, ["/bin"]); + var entries = launchPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + + entries.Should().OnlyHaveUniqueItems(); + entries.Should().Contain("/usr/bin"); + entries.Should().Contain("/bin"); + } + + [Fact] + public void ResolveCopilotCliPath_UsesAdditionalSearchPaths() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + try + { + var binaryName = OperatingSystem.IsWindows() ? "copilot.exe" : "copilot"; + var expectedPath = Path.Combine(tempDir, binaryName); + File.WriteAllText(expectedPath, string.Empty); + + var resolvedPath = CopilotService.ResolveCopilotCliPath( + pathEnv: string.Empty, + baseDirectory: tempDir, + additionalSearchPaths: [expectedPath]); + + resolvedPath.Should().Be(expectedPath); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} From d3fac28f7faac1c6bb8173c3739abf4083f5d82d Mon Sep 17 00:00:00 2001 From: Redth Date: Fri, 17 Apr 2026 14:54:46 -0400 Subject: [PATCH 3/3] Prefer installed Copilot CLI in release builds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotService.cs | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/MauiSherpa.Core/Services/CopilotService.cs b/src/MauiSherpa.Core/Services/CopilotService.cs index 7a9f98a..1a7a779 100644 --- a/src/MauiSherpa.Core/Services/CopilotService.cs +++ b/src/MauiSherpa.Core/Services/CopilotService.cs @@ -226,8 +226,9 @@ private static Dictionary BuildCliEnvironment(string? cliPath = } /// - /// Resolves the Copilot CLI binary path. Checks the bundled runtimes path first, - /// then common install locations for GUI-launched apps, and finally the system PATH. + /// Resolves the Copilot CLI binary path. Prefer the user's installed CLI when available + /// so release builds can reuse the normal auth state and avoid bundle-signing edge cases, + /// then fall back to the bundled runtimes copy. /// internal static string? ResolveCopilotCliPath(string? pathEnv = null, string? baseDirectory = null, IEnumerable? additionalSearchPaths = null) { @@ -235,30 +236,6 @@ private static Dictionary BuildCliEnvironment(string? cliPath = var binaryName = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( System.Runtime.InteropServices.OSPlatform.Windows) ? "copilot.exe" : "copilot"; - var candidateBaseDirs = new[] - { - baseDirectory ?? AppContext.BaseDirectory, - Path.GetFullPath(Path.Combine(baseDirectory ?? AppContext.BaseDirectory, "..")), - Path.GetFullPath(Path.Combine(baseDirectory ?? AppContext.BaseDirectory, "..", "MonoBundle")) - } - .Distinct(StringComparer.Ordinal) - .ToArray(); - - foreach (var root in candidateBaseDirs) - { - var bundledPath = Path.Combine(root, "runtimes", rid, "native", binaryName); - if (File.Exists(bundledPath)) - return bundledPath; - - if (rid.StartsWith("maccatalyst-", StringComparison.OrdinalIgnoreCase)) - { - var osxRid = rid.Replace("maccatalyst-", "osx-"); - var osxPath = Path.Combine(root, "runtimes", osxRid, "native", binaryName); - if (File.Exists(osxPath)) - return osxPath; - } - } - foreach (var candidate in additionalSearchPaths ?? Enumerable.Empty()) { if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate)) @@ -310,6 +287,30 @@ private static Dictionary BuildCliEnvironment(string? cliPath = return candidate; } + var candidateBaseDirs = new[] + { + baseDirectory ?? AppContext.BaseDirectory, + Path.GetFullPath(Path.Combine(baseDirectory ?? AppContext.BaseDirectory, "..")), + Path.GetFullPath(Path.Combine(baseDirectory ?? AppContext.BaseDirectory, "..", "MonoBundle")) + } + .Distinct(StringComparer.Ordinal) + .ToArray(); + + foreach (var root in candidateBaseDirs) + { + var bundledPath = Path.Combine(root, "runtimes", rid, "native", binaryName); + if (File.Exists(bundledPath)) + return bundledPath; + + if (rid.StartsWith("maccatalyst-", StringComparison.OrdinalIgnoreCase)) + { + var osxRid = rid.Replace("maccatalyst-", "osx-"); + var osxPath = Path.Combine(root, "runtimes", osxRid, "native", binaryName); + if (File.Exists(osxPath)) + return osxPath; + } + } + return null; }