diff --git a/CodeiumVS/CodeiumVS.csproj b/CodeiumVS/CodeiumVS.csproj index 06eb4dc..ad61fbe 100644 --- a/CodeiumVS/CodeiumVS.csproj +++ b/CodeiumVS/CodeiumVS.csproj @@ -69,6 +69,7 @@ + diff --git a/CodeiumVS/CodeiumVSPackage.cs b/CodeiumVS/CodeiumVSPackage.cs index f73bf69..7090a36 100644 --- a/CodeiumVS/CodeiumVSPackage.cs +++ b/CodeiumVS/CodeiumVSPackage.cs @@ -192,7 +192,7 @@ static string CleanifyBrowserPath(string p) } // Try three different ways to open url in the default browser - public void OpenInBrowser(string url) + public static void OpenInBrowser(string url) { Action[] methods = [ (_url) => { @@ -215,11 +215,11 @@ public void OpenInBrowser(string url) } catch (Exception ex) { - Log($"Could not open in browser, encountered an exception: {ex}\n Retrying using another method"); + Instance?.Log($"Could not open in browser, encountered an exception: {ex}\n Retrying using another method"); } } - Log($"Codeium failed to open the browser, please use this URL instead: {url}"); + Instance?.Log($"Codeium failed to open the browser, please use this URL instead: {url}"); VS.MessageBox.Show("Codeium: Failed to open browser", $"Please use this URL instead (you can copy from the output window):\n{url}"); } diff --git a/CodeiumVS/LanguageServer/LanguageServer.cs b/CodeiumVS/LanguageServer/LanguageServer.cs index 8fb1b4a..bfaad2e 100644 --- a/CodeiumVS/LanguageServer/LanguageServer.cs +++ b/CodeiumVS/LanguageServer/LanguageServer.cs @@ -1,17 +1,19 @@ using CodeiumVS.Packets; -using EnvDTE80; using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Imaging; using Microsoft.VisualStudio.Shell.Interop; using Microsoft.VisualStudio.Threading; using Newtonsoft.Json; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Compression; using System.Net; using System.Net.Http; -using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -21,476 +23,688 @@ namespace CodeiumVS; public class LanguageServer { - private const string Version = "1.6.10"; - - private int Port = 0; - private Process process; - - private readonly Metadata Metadata; - private readonly HttpClient HttpClient; - private readonly CodeiumVSPackage Package; - private readonly NotificationInfoBar NotificationDownloading; - - public readonly LanguageServerController Controller; - - public LanguageServer() - { - NotificationDownloading = new NotificationInfoBar(); - - Package = CodeiumVSPackage.Instance; - HttpClient = new HttpClient(); - Controller = new LanguageServerController(); - Metadata = new(); - } - - public async Task InitializeAsync() - { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - - string ideVersion = "17.0", locale = "en-US"; - + private string _languageServerURL; + private string _languageServerVersion = "1.6.10"; + + private int _port = 0; + private Process _process; + + private readonly Metadata _metadata; + private readonly HttpClient _httpClient; + private readonly CodeiumVSPackage _package; + + public readonly LanguageServerController Controller; + + public LanguageServer() + { + _package = CodeiumVSPackage.Instance; + _metadata = new(); + _httpClient = new HttpClient(); + Controller = new LanguageServerController(); + } + + public async Task InitializeAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + string ideVersion = "17.0", locale = "en-US"; + + try + { + locale = CultureInfo.CurrentUICulture.Name; + Version? version = await VS.Shell.GetVsVersionAsync(); + if (version != null) ideVersion = version.ToString(); + } + catch (Exception) { } + + // must be called before setting the metadata to retrieve _languageServerVersion first + await PrepareAsync(); + + _metadata.request_id = 0; + _metadata.ide_name = "visual_studio"; + _metadata.ide_version = ideVersion; + _metadata.extension_name = Vsix.Name; + _metadata.extension_version = _languageServerVersion; + _metadata.session_id = Guid.NewGuid().ToString(); + _metadata.locale = locale; + _metadata.disable_telemetry = false; + } + + public void Dispose() + { + // HasExited can throw, i don't know we should properly handle it try { - locale = CultureInfo.CurrentUICulture.Name; - Version? version = await VS.Shell.GetVsVersionAsync(); - if (version != null) ideVersion = version.ToString(); - } - catch (Exception) { } - - Metadata.request_id = 0; - Metadata.ide_name = "visual_studio"; - Metadata.ide_version = ideVersion; - Metadata.extension_name = Vsix.Name; - Metadata.extension_version = Version; - Metadata.session_id = Guid.NewGuid().ToString(); - Metadata.locale = locale; - Metadata.disable_telemetry = false; - - await PrepareAsync(); - } - - public void Dispose() - { - if (process != null && !process.HasExited) - { - process.Kill(); - process.Dispose(); - process = null; - } - - Controller.Disconnect(); - } - - public int GetPort() { return Port; } - public string GetKey() { return Metadata.api_key; } - public string GetVersion() { return Version; } - public bool IsReady() { return Port != 0; } - public async Task WaitReadyAsync() { while (!IsReady()) {await Task.Delay(50);} } - - // Get API key from the authentication token - public async Task SignInWithAuthTokenAsync(string authToken) - { - string url = Package.SettingsPage.EnterpriseMode ? - Package.SettingsPage.ApiUrl + "/exa.seat_management_pb.SeatManagementService/RegisterUser" : - "https://api.codeium.com/register_user/"; - - RegisterUserRequest data = new() { firebase_id_token = authToken }; - RegisterUserResponse result = await RequestUrlAsync(url, data); - - Metadata.api_key = result.api_key; - - if (Metadata.api_key == null) - { - await Package.LogAsync("Failed to sign in."); - - // show an error message box - var msgboxResult = await VS.MessageBox.ShowAsync( - "Codeium: Failed to sign in. Please check the output window for more details.", - "Do you want to retry?", - OLEMSGICON.OLEMSGICON_INFO, - OLEMSGBUTTON.OLEMSGBUTTON_RETRYCANCEL, - OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST - ); - - if (msgboxResult == VSConstants.MessageBoxResult.IDRETRY) - await SignInWithAuthTokenAsync(authToken); - - return; - } - - File.WriteAllText(Package.GetAPIKeyPath(), Metadata.api_key); - await Package.LogAsync("Signed in successfully"); - await Package.UpdateSignedInStateAsync(); - } - - // Open the browser to sign in - public async Task SignInAsync() - { - // this will block until the sign in process has finished - async Task WaitForAuthTokenAsync() - { - // wait until we got the actual port of the LSP - await WaitReadyAsync(); - - // TODO: should we use timeout = Timeout.InfiniteTimeSpan? default value is 100s (1m40s) - GetAuthTokenResponse? result = await RequestCommandAsync("GetAuthToken", new {}); - - if (result == null) + if (_process != null && !_process.HasExited) { - // show an error message box - var msgboxResult = await VS.MessageBox.ShowAsync( - "Codeium: Failed to get the Authentication Token. Please check the output window for more details.", - "Do you want to retry?", - OLEMSGICON.OLEMSGICON_INFO, - OLEMSGBUTTON.OLEMSGBUTTON_RETRYCANCEL, - OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST - ); - - return (msgboxResult == VSConstants.MessageBoxResult.IDRETRY) ? await WaitForAuthTokenAsync() : null; + _process.Kill(); + _process.Dispose(); + _process = null; } - - return result.authToken; } - - string state = Guid.NewGuid().ToString(); - string portalUrl = Package.SettingsPage.EnterpriseMode ? Package.SettingsPage.PortalUrl : "https://www.codeium.com"; - string redirectUrl = Uri.EscapeDataString($"http://127.0.0.1:{Port}/auth"); - string url = $"{portalUrl}/profile?response_type=token&redirect_uri={redirectUrl}&state={state}&scope=openid%20profile%20email&redirect_parameters_type=query"; - - await Package.LogAsync("Opening browser to " + url); - - Package.OpenInBrowser(url); - - string authToken = await WaitForAuthTokenAsync(); - if (authToken != null) await SignInWithAuthTokenAsync(authToken); - } - - // Delete the stored API key - public async Task SignOutAsync() - { - Metadata.api_key = ""; - File.Delete(Package.GetAPIKeyPath()); - await Package.LogAsync("Signed out successfully"); - await Package.UpdateSignedInStateAsync(); - } - - // Download the language server (if not already) and start it - public async Task PrepareAsync() - { - string binaryPath = Package.GetLanguageServerPath(); - - if (File.Exists(binaryPath)) + catch (Exception) { } + + Controller.Disconnect(); + } + + public int GetPort() { return _port; } + public string GetKey() { return _metadata.api_key; } + public string GetVersion() { return _languageServerVersion; } + public bool IsReady() { return _port != 0; } + public async Task WaitReadyAsync() { while (!IsReady()) {await Task.Delay(50);} } + + // Get API key from the authentication token + public async Task SignInWithAuthTokenAsync(string authToken) + { + string url = _package.SettingsPage.EnterpriseMode ? + _package.SettingsPage.ApiUrl + "/exa.seat_management_pb.SeatManagementService/RegisterUser" : + "https://api.codeium.com/register_user/"; + + RegisterUserRequest data = new() { firebase_id_token = authToken }; + RegisterUserResponse result = await RequestUrlAsync(url, data); + + _metadata.api_key = result.api_key; + + if (_metadata.api_key == null) + { + await _package.LogAsync("Failed to sign in."); + + // show an error message box + var msgboxResult = await VS.MessageBox.ShowAsync( + "Codeium: Failed to sign in. Please check the output window for more details.", + "Do you want to retry?", + OLEMSGICON.OLEMSGICON_WARNING, + OLEMSGBUTTON.OLEMSGBUTTON_RETRYCANCEL, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST + ); + + if (msgboxResult == VSConstants.MessageBoxResult.IDRETRY) + await SignInWithAuthTokenAsync(authToken); + + return; + } + + File.WriteAllText(_package.GetAPIKeyPath(), _metadata.api_key); + await _package.LogAsync("Signed in successfully"); + await _package.UpdateSignedInStateAsync(); + } + + // Open the browser to sign in + public async Task SignInAsync() + { + // this will block until the sign in process has finished + async Task WaitForAuthTokenAsync() + { + // wait until we got the actual port of the LSP + await WaitReadyAsync(); + + // TODO: should we use timeout = Timeout.InfiniteTimeSpan? default value is 100s (1m40s) + GetAuthTokenResponse? result = await RequestCommandAsync("GetAuthToken", new {}); + + if (result == null) + { + // show an error message box + var msgboxResult = await VS.MessageBox.ShowAsync( + "Codeium: Failed to get the Authentication Token. Please check the output window for more details.", + "Do you want to retry?", + OLEMSGICON.OLEMSGICON_WARNING, + OLEMSGBUTTON.OLEMSGBUTTON_RETRYCANCEL, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST + ); + + return (msgboxResult == VSConstants.MessageBoxResult.IDRETRY) ? await WaitForAuthTokenAsync() : null; + } + + return result.authToken; + } + + string state = Guid.NewGuid().ToString(); + string portalUrl = _package.SettingsPage.EnterpriseMode ? _package.SettingsPage.PortalUrl : "https://www.codeium.com"; + string redirectUrl = Uri.EscapeDataString($"http://127.0.0.1:{_port}/auth"); + string url = $"{portalUrl}/profile?response_type=token&redirect_uri={redirectUrl}&state={state}&scope=openid%20profile%20email&redirect_parameters_type=query"; + + await _package.LogAsync("Opening browser to " + url); + + CodeiumVSPackage.OpenInBrowser(url); + + string authToken = await WaitForAuthTokenAsync(); + if (authToken != null) await SignInWithAuthTokenAsync(authToken); + } + + // Delete the stored API key + public async Task SignOutAsync() + { + _metadata.api_key = ""; + Utilities.FileUtilities.DeleteSafe(_package.GetAPIKeyPath()); + await _package.LogAsync("Signed out successfully"); + await _package.UpdateSignedInStateAsync(); + } + + /// + /// Get the language server URL and version, from the portal if we are in enterprise mode + /// + /// + private async Task GetLanguageServerInfoAsync() + { + string extensionBaseUrl = "https://github.com/Exafunction/codeium/releases/download"; + + if (_package.SettingsPage.EnterpriseMode) + { + // Get the contents of /api/extension_base_url + try + { + string portalUrl = _package.SettingsPage.PortalUrl.TrimEnd('/'); + string result = await new HttpClient().GetStringAsync(portalUrl + "/api/extension_base_url"); + extensionBaseUrl = result.Trim().TrimEnd('/'); + _languageServerVersion = await new HttpClient().GetStringAsync(portalUrl + "/api/version"); + } + catch (Exception) + { + await _package.LogAsync("Failed to get extension base url"); + extensionBaseUrl = "https://github.com/Exafunction/codeium/releases/download"; + } + } + + _languageServerURL = $"{extensionBaseUrl}/language-server-v{_languageServerVersion}/language_server_windows_x64.exe.gz"; + } + + /// + /// Update the progress dialog percentage + /// + private async Task ThreadDownload_UpdateProgressAsync(DownloadProgressChangedEventArgs e, IVsThreadedWaitDialog4 progressDialog) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + double totalBytesMb = e.TotalBytesToReceive / 1024.0 / 1024.0; + double recievedBytesMb = e.BytesReceived / 1024.0 / 1024.0; + + progressDialog.UpdateProgress( + $"Downloading language server v{_languageServerVersion} ({e.ProgressPercentage}%)", + $"{recievedBytesMb:f2}Mb / {totalBytesMb:f2}Mb", + $"Codeium: Downloading language server v{_languageServerVersion} ({e.ProgressPercentage}%)", + (int)e.BytesReceived, (int)e.TotalBytesToReceive, true, out _ + ); + } + + /// + /// On download completed, extract the language server from the archive and start it. Prompt the user to retry if failed. + /// + private async Task ThreadDownload_OnCompletedAsync(AsyncCompletedEventArgs e, IVsThreadedWaitDialog4 progressDialog, string downloadDest) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + progressDialog.StartWaitDialog( + "Codeium", $"Extracting files...", "Almost done", null, + $"Codeium: Extracting files...", 0, false, true + ); + + // show a notification to ask the user if they wish to retry downloading it + if (e.Error != null) { - await StartAsync(); - return; - } - - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - await Package.LogAsync("Downloading language server..."); - - // show the downloading progress dialog - var waitDialogFactory = (IVsThreadedWaitDialogFactory)await VS.Services.GetThreadedWaitDialogAsync(); - IVsThreadedWaitDialog4 progDialog = waitDialogFactory.CreateInstance(); + await _package.LogAsync($"ThreadDownload_OnCompletedAsync: Failed to download the language server; Exception: {e.Error}"); + NotificationInfoBar errorBar = new(); + KeyValuePair[] actions = [ + new KeyValuePair("Retry", delegate + { + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + await errorBar.CloseAsync(); + await PrepareAsync(); + }).FireAndForget(); + }), + ]; + + errorBar.Show( + "[Codeium] Critical Error: Failed to download the language server. Do you want to retry?", + KnownMonikers.StatusError, true, null, [.. actions, .. NotificationInfoBar.SupportActions] + ); + } + else + { + // extract the language server archive + await _package.LogAsync("Extracting language server..."); + using FileStream fileStream = new(downloadDest, FileMode.Open); + using GZipStream gzipStream = new(fileStream, CompressionMode.Decompress); + using FileStream outputStream = new(_package.GetLanguageServerPath(), FileMode.Create); - string extensionBaseUrl = "https://github.com/Exafunction/codeium/releases/download"; - string languageServerVersion = Version; - if (Package.SettingsPage.EnterpriseMode) - { - // Get the contents of /api/extension_base_url + // if there were an error during extraction, the `StartAsync` + // function can handle it, so we don't need to do it here try { - string portalUrl = Package.SettingsPage.PortalUrl.TrimEnd('/'); - string result = await new HttpClient().GetStringAsync(portalUrl + "/api/extension_base_url"); - extensionBaseUrl = result.Trim().TrimEnd('/'); - languageServerVersion = await new HttpClient().GetStringAsync(portalUrl + "/api/version"); + await gzipStream.CopyToAsync(outputStream); } - catch (Exception) + catch (Exception ex) { - await Package.LogAsync("Failed to get extension base url"); - extensionBaseUrl = "https://github.com/Exafunction/codeium/releases/download"; + await _package.LogAsync($"ThreadDownload_OnCompletedAsync: Error during extraction; Exception: {ex}"); } - } - Metadata.extension_version = languageServerVersion; - - progDialog.StartWaitDialog( - "Codeium", $"Downloading language server v{languageServerVersion}", "", null, - $"Codeium: Downloading language server v{languageServerVersion}", 0, false, true - ); - // the language server is downloaded in a thread so that it doesn't block the UI - // if we remove `while (webClient.IsBusy)`, the DownloadProgressChanged callback won't be called - // until VS is closing, not sure how we can fix that without spawning a separate thread - void ThreadDownloadLanguageServer() - { - string langServerFolder = Package.GetLanguageServerFolder(); - string downloadDest = Path.Combine(langServerFolder, "language-server.gz"); + outputStream.Close(); + gzipStream.Close(); + fileStream.Close(); + } - Directory.CreateDirectory(langServerFolder); - if (File.Exists(downloadDest)) File.Delete(downloadDest); + Utilities.FileUtilities.DeleteSafe(downloadDest); + + progressDialog.EndWaitDialog(); + (progressDialog as IDisposable)?.Dispose(); + + if (e.Error == null) await StartAsync(); + } + + /// + /// The language server is downloaded in a thread so that it doesn't block the UI.
+ /// Iff we remove `while (webClient.IsBusy)`, the DownloadProgressChanged callback won't be called
+ /// until VS is closing, not sure how we can fix that without spawning a separate thread. + ///
+ /// + private void ThreadDownloadLanguageServer(IVsThreadedWaitDialog4 progressDialog) + { + string langServerFolder = _package.GetLanguageServerFolder(); + string downloadDest = Path.GetTempFileName(); + + Directory.CreateDirectory(langServerFolder); + Utilities.FileUtilities.DeleteSafe(downloadDest); + + Uri url = new(_languageServerURL); + WebClient webClient = new(); + + int oldPercent = -1; + + webClient.DownloadProgressChanged += (s, e) => + { + // don't update the progress bar too often + if (e.ProgressPercentage == oldPercent) return; + oldPercent = e.ProgressPercentage; + + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + await ThreadDownload_UpdateProgressAsync(e, progressDialog); + }).FireAndForget(); + }; + + webClient.DownloadFileCompleted += (s, e) => + { + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + await ThreadDownload_OnCompletedAsync(e, progressDialog, downloadDest); + }).FireAndForget(); + }; + + // set no-cache so that we don't have unexpected problems + webClient.CachePolicy = new System.Net.Cache.RequestCachePolicy(System.Net.Cache.RequestCacheLevel.NoCacheNoStore); + + // start downloading and wait for it to finish + webClient.DownloadFileAsync(url, downloadDest); + + // wait until the download is completed + while (webClient.IsBusy) + Thread.Sleep(100); + + webClient.Dispose(); + } + + // Download the language server (if not already) and start it + public async Task PrepareAsync() + { + await GetLanguageServerInfoAsync(); + string binaryPath = _package.GetLanguageServerPath(); + + if (File.Exists(binaryPath)) + { + await StartAsync(); + return; + } + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + await _package.LogAsync($"Downloading language server v{_languageServerVersion}"); + + // show the downloading progress dialog before starting the thread to make it feels more responsive + var waitDialogFactory = (IVsThreadedWaitDialogFactory)await VS.Services.GetThreadedWaitDialogAsync(); + IVsThreadedWaitDialog4 progressDialog = waitDialogFactory.CreateInstance(); + + progressDialog.StartWaitDialog( + "Codeium", $"Downloading language server v{_languageServerVersion}", "", null, + $"Codeium: Downloading language server v{_languageServerVersion}", 0, false, true + ); + + Thread trd = new(() => ThreadDownloadLanguageServer(progressDialog)) + { + IsBackground = true + }; + + trd.Start(); + } + + /// + /// Verify the language server digital signature. If invalid, prompt the user to re-download it, or ignore and continue. + /// + /// False if the signature is invalid + private async Task VerifyLanguageServerSignatureAsync() + { + try + { + X509Certificate2 certificate = new(_package.GetLanguageServerPath()); + RSACryptoServiceProvider publicKey = (RSACryptoServiceProvider)certificate.PublicKey.Key; + if (certificate.Verify()) return true; + } + catch (CryptographicException) { } - Uri url = new($"{extensionBaseUrl}/language-server-v{languageServerVersion}/language_server_windows_x64.exe.gz"); - WebClient webClient = new(); + await _package.LogAsync("LanguageServer.VerifyLanguageServerSignatureAsync: Failed to verify the language server digital signature"); - int oldPercent = -1; - webClient.DownloadProgressChanged += (s, e) => + NotificationInfoBar errorBar = new(); + KeyValuePair[] actions = [ + new KeyValuePair("Re-download", delegate { - // don't update the progress bar too often - if (e.ProgressPercentage != oldPercent) + // delete the language server exe and try to re-download the language server + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate { - oldPercent = e.ProgressPercentage; - ThreadHelper.JoinableTaskFactory.RunAsync(async delegate - { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - - double totalBytesMb = e.TotalBytesToReceive / 1024.0 / 1024.0; - double recievedBytesMb = e.BytesReceived / 1024.0 / 1024.0; - - progDialog.UpdateProgress( - $"Downloading language server v{languageServerVersion} ({e.ProgressPercentage}%)", - $"{recievedBytesMb:f2}Mb / {totalBytesMb:f2}Mb", - $"Codeium: Downloading language server v{languageServerVersion} ({e.ProgressPercentage}%)", - (int)e.BytesReceived, (int)e.TotalBytesToReceive, true, out _ - ); - - }).FireAndForget(true); - } - }; - - webClient.DownloadFileCompleted += (s, e) => + Utilities.FileUtilities.DeleteSafe(_package.GetLanguageServerPath()); + await errorBar.CloseAsync(); + await PrepareAsync(); + }).FireAndForget(); + }), + new KeyValuePair("Ignore and continue", delegate { + // ignore the invalid signature and just try to start the language server ThreadHelper.JoinableTaskFactory.RunAsync(async delegate { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - - progDialog.StartWaitDialog( - "Codeium", $"Extracting files...", "Almost done", null, - $"Codeium: Extracting files...", 0, false, true - ); - - await Package.LogAsync("Extracting language server..."); - using FileStream fileStream = new(downloadDest, FileMode.Open); - using GZipStream gzipStream = new(fileStream, CompressionMode.Decompress); - using FileStream outputStream = new(Package.GetLanguageServerPath(), FileMode.Create); - await gzipStream.CopyToAsync(outputStream); - - outputStream.Close(); - gzipStream.Close(); - fileStream.Close(); - - progDialog.EndWaitDialog(); - (progDialog as IDisposable).Dispose(); - - await StartAsync(); - }).FireAndForget(true); - }; - - // set no-cache so that we don't have unexpected problems - webClient.CachePolicy = new System.Net.Cache.RequestCachePolicy(System.Net.Cache.RequestCacheLevel.NoCacheNoStore); - - // start downloading and wait for it to finish - webClient.DownloadFileAsync(url, downloadDest); + await errorBar.CloseAsync(); + await StartAsync(true); + }).FireAndForget(); + }), + ]; - while (webClient.IsBusy) - Thread.Sleep(100); - } + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + errorBar.Show( + "[Codeium] Failed to verify the language server digital signature. The executable might be corrupted.", + KnownMonikers.IntellisenseWarning, true, null, actions + ); - Thread trd = new(new ThreadStart(ThreadDownloadLanguageServer)) - { - IsBackground = true - }; - trd.Start(); + return false; } - // Start the language server process - private async Task StartAsync() - { - Port = 0; + /// + /// Start the language server process and begin reading its pipe output. + /// + /// If true, ignore the digital signature verification + private async Task StartAsync(bool ignoreDigitalSignature = false) + { + _port = 0; - string apiUrl = (Package.SettingsPage.ApiUrl.Equals("") ? "https://server.codeium.com" : Package.SettingsPage.ApiUrl); - string managerDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - string databaseDir = Package.GetDatabaseDirectory(); + if (!ignoreDigitalSignature && !await VerifyLanguageServerSignatureAsync()) + return; + + string apiUrl = (_package.SettingsPage.ApiUrl.Equals("") ? "https://server.codeium.com" : _package.SettingsPage.ApiUrl); + string managerDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + string databaseDir = _package.GetDatabaseDirectory(); + string languageServerPath = _package.GetLanguageServerPath(); try - { - Directory.CreateDirectory(managerDir); - Directory.CreateDirectory(databaseDir); - } - catch (Exception ex) - { - await Package.LogAsync($"LanguageServer.StartAsync: Failed to create directories; Exception: {ex}"); - await VS.MessageBox.ShowErrorAsync( - "Codeium: Failed to create language server directories.", - "Please check the output window for more details." + { + Directory.CreateDirectory(managerDir); + Directory.CreateDirectory(databaseDir); + } + catch (Exception ex) + { + await _package.LogAsync($"LanguageServer.StartAsync: Failed to create directories; Exception: {ex}"); + + new NotificationInfoBar().Show( + "[Codeium] Critical error: Failed to create language server directories. Please check the output window for more details.", + KnownMonikers.StatusError, true, null, NotificationInfoBar.SupportActions ); return; - } + } - process = new(); - process.StartInfo.FileName = Package.GetLanguageServerPath(); - process.StartInfo.UseShellExecute = false; - process.StartInfo.CreateNoWindow = true; - process.StartInfo.RedirectStandardError = true; - process.EnableRaisingEvents = true; + _process = new(); + _process.StartInfo.FileName = languageServerPath; + _process.StartInfo.UseShellExecute = false; + _process.StartInfo.CreateNoWindow = true; + _process.StartInfo.RedirectStandardError = true; + _process.EnableRaisingEvents = true; - process.StartInfo.Arguments = - $"--api_server_url {apiUrl} --manager_dir \"{managerDir}\" --database_dir \"{databaseDir}\"" + - " --enable_chat_web_server --enable_chat_client --detect_proxy=false"; + _process.StartInfo.Arguments = + $"--api_server_url {apiUrl} --manager_dir \"{managerDir}\" --database_dir \"{databaseDir}\"" + + " --enable_chat_web_server --enable_chat_client --detect_proxy=false"; - if (Package.SettingsPage.EnterpriseMode) - process.StartInfo.Arguments += $" --enterprise_mode --portal_url {Package.SettingsPage.PortalUrl}"; + if (_package.SettingsPage.EnterpriseMode) + _process.StartInfo.Arguments += $" --enterprise_mode --portal_url {_package.SettingsPage.PortalUrl}"; - process.ErrorDataReceived += LSP_OnPipeDataReceived; - process.Exited += LSP_OnExited; + _process.ErrorDataReceived += LSP_OnPipeDataReceived; + _process.Exited += LSP_OnExited; - await Package.LogAsync("Starting language server"); + await _package.LogAsync("Starting language server"); + // try to start the process, if it fails, prompt the user if they + // wish to delete the language server exe and restart VS try { - process.Start(); - process.BeginErrorReadLine(); - Utilities.ProcessExtensions.MakeProcessExitOnParentExit(process); - } - catch (Exception ex) + _process.Start(); + } + catch (Exception ex) { - await Package.LogAsync($"LanguageServer.StartAsync: Failed to start the language server; Exception: {ex}"); - await VS.MessageBox.ShowErrorAsync( - "Codeium: Failed to start the language server.", - "Please check the output window for more details." + // ask the user if they wish to delete the language server exe and try to re-download it + + _process = null; + await _package.LogAsync($"LanguageServer.StartAsync: Failed to start the language server; Exception: {ex}"); + + NotificationInfoBar errorBar = new(); + KeyValuePair[] actions = [ + new KeyValuePair("Retry", delegate + { + // delete the language server exe and try to re-download the language server + _process = null; + Utilities.FileUtilities.DeleteSafe(languageServerPath); + + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + await errorBar.CloseAsync(); + await PrepareAsync(); + }).FireAndForget(); + }), + ]; + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + errorBar.Show( + "[Codeium] Critical Error: Failed to start the language server. Do you want to retry?", + KnownMonikers.StatusError, true, null, [.. actions, .. NotificationInfoBar.SupportActions] ); - } - - string apiKeyFilePath = Package.GetAPIKeyPath(); - if (File.Exists(apiKeyFilePath)) - { - Metadata.api_key = File.ReadAllText(apiKeyFilePath); - } - - await Package.UpdateSignedInStateAsync(); - } - - private void LSP_OnExited(object sender, EventArgs e) - { - Package.Log("Language Server Process exited unexpectedly, restarting..."); - - Port = 0; - process = null; - Controller.Disconnect(); - ThreadHelper.JoinableTaskFactory.RunAsync(StartAsync).FireAndForget(true); - } - - // This method will be responsible for reading and parsing the output of the LSP - private void LSP_OnPipeDataReceived(object sender, DataReceivedEventArgs e) - { - if (string.IsNullOrEmpty(e.Data)) return; - - // regex to match the port number - Match match = Regex.Match(e.Data, @"Language server listening on (random|fixed) port at (\d{2,5})"); - - if (match.Success) - { - if (int.TryParse(match.Groups[2].Value, out Port)) - { - Package.Log($"Language server started on port {Port}"); - ChatToolWindow.Instance?.Reload(); - ThreadHelper.JoinableTaskFactory.RunAsync(Controller.ConnectAsync).FireAndForget(true); - } - else - { - Package.Log($"Error: Failed to parse the port number from \"{match.Groups[1].Value}\""); - } + return; } - Package.Log("Language Server: " + e.Data); - } - - private async Task RequestUrlAsync(string url, object data, CancellationToken cancellationToken = default) - { - StringContent post_data = new(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); + // try to read the pipe output, this is a mild error if it fails try { - HttpResponseMessage rq = await HttpClient.PostAsync(url, post_data, cancellationToken); - if (rq.StatusCode == HttpStatusCode.OK) - { - return JsonConvert.DeserializeObject(await rq.Content.ReadAsStringAsync()); - } - - await Package.LogAsync($"Error: Failed to send request to {url}, status code: {rq.StatusCode}"); - } - catch (OperationCanceledException) { } - catch (Exception ex) + _process.BeginErrorReadLine(); + } + catch (Exception ex) { - await Package.LogAsync($"Error: Failed to send request to {url}, exception: {ex.Message}"); - } + await _package.LogAsync($"LanguageServer.StartAsync: BeginErrorReadLine failed; Exception: {ex}"); - return default; - } + // warn the user about the issue + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + new NotificationInfoBar().Show( + "[Codeium] Failed to read output from the language server, Codeium might not work properly.", + KnownMonikers.IntellisenseWarning, true, null, NotificationInfoBar.SupportActions + ); - private async Task RequestCommandAsync(string command, object data, CancellationToken cancellationToken = default) - { - string url = $"http://127.0.0.1:{Port}/exa.language_server_pb.LanguageServerService/{command}"; - return await RequestUrlAsync(url, data, cancellationToken); - } + // fall back to reading the port file + var timeoutSec = 120; + var elapsedSec = 0; - public async Task?> GetCompletionsAsync(string absolutePath, string text, Languages.LangInfo language, int cursorPosition, string lineEnding, int tabSize, bool insertSpaces, CancellationToken token) - { - GetCompletionsRequest data = new() - { - metadata = GetMetadata(), - document = new() - { - text = text, - editor_language = language.Name, - language = language.Type, - cursor_offset = (ulong)cursorPosition, - line_ending = lineEnding, - absolute_path = absolutePath, - relative_path = Path.GetFileName(absolutePath) - }, - editor_options = new() + while (elapsedSec++ < timeoutSec) { - tab_size = (ulong)tabSize, - insert_spaces = insertSpaces, - disable_autocomplete_in_comments = !Package.SettingsPage.EnableCommentCompletion, - } - }; + // Check for new files in the directory + var files = Directory.GetFiles(managerDir); - GetCompletionsResponse? result = await RequestCommandAsync("GetCompletions", data, token); - return result != null ? result.completionItems : []; - } + foreach (var file in files) + { + if (int.TryParse(Path.GetFileName(file), out _port) && _port != 0) + break; + } - public async Task AcceptCompletionAsync(string completionId) - { - AcceptCompletionRequest data = new() - { - metadata = GetMetadata(), - completion_id = completionId - }; + if (_port != 0) break; - await RequestCommandAsync("AcceptCompletion", data); - } + // Wait for a short time before checking again + await Task.Delay(1000); + } - public async Task GetProcessesAsync() - { - return await RequestCommandAsync("GetProcesses", new { }); - } + if (_port != 0) + { + ThreadHelper.JoinableTaskFactory.RunAsync(Controller.ConnectAsync).FireAndForget(true); + } + else + { + new NotificationInfoBar().Show( + "[Codeium] Critical Error: Failed to get the language server port. Please check the output window for more details.", + KnownMonikers.StatusError, true, null, NotificationInfoBar.SupportActions + ); - public Metadata GetMetadata() - { - return new() - { - request_id = Metadata.request_id++, - api_key = Metadata.api_key, - ide_name = Metadata.ide_name, - ide_version = Metadata.ide_version, - extension_name = Metadata.extension_name, - extension_version = Metadata.extension_version, - session_id = Metadata.session_id, - locale = Metadata.locale, - disable_telemetry = Metadata.disable_telemetry - }; - } + return; + } + } + + if (!Utilities.ProcessExtensions.MakeProcessExitOnParentExit(_process)) + { + await _package.LogAsync("LanguageServer.StartAsync: MakeProcessExitOnParentExit failed"); + } + + string apiKeyFilePath = _package.GetAPIKeyPath(); + if (File.Exists(apiKeyFilePath)) + { + _metadata.api_key = File.ReadAllText(apiKeyFilePath); + } + + await _package.UpdateSignedInStateAsync(); + } + + private void LSP_OnExited(object sender, EventArgs e) + { + _package.Log("Language Server Process exited unexpectedly, restarting..."); + + _port = 0; + _process = null; + Controller.Disconnect(); + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + await StartAsync(); + + }).FireAndForget(true); + } + + // This method will be responsible for reading and parsing the output of the LSP + private void LSP_OnPipeDataReceived(object sender, DataReceivedEventArgs e) + { + if (string.IsNullOrEmpty(e.Data)) return; + + // regex to match the port number + Match match = Regex.Match(e.Data, @"Language server listening on (random|fixed) port at (\d{2,5})"); + + if (match.Success) + { + if (int.TryParse(match.Groups[2].Value, out _port)) + { + _package.Log($"Language server started on port {_port}"); + + ChatToolWindow.Instance?.Reload(); + ThreadHelper.JoinableTaskFactory.RunAsync(Controller.ConnectAsync).FireAndForget(true); + } + else + { + _package.Log($"Error: Failed to parse the port number from \"{match.Groups[1].Value}\""); + } + } + + _package.Log("Language Server: " + e.Data); + } + + private async Task RequestUrlAsync(string url, object data, CancellationToken cancellationToken = default) + { + StringContent post_data = new(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); + try + { + HttpResponseMessage rq = await _httpClient.PostAsync(url, post_data, cancellationToken); + if (rq.StatusCode == HttpStatusCode.OK) + { + return JsonConvert.DeserializeObject(await rq.Content.ReadAsStringAsync()); + } + + await _package.LogAsync($"Error: Failed to send request to {url}, status code: {rq.StatusCode}"); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + await _package.LogAsync($"Error: Failed to send request to {url}, exception: {ex.Message}"); + } + + return default; + } + + private async Task RequestCommandAsync(string command, object data, CancellationToken cancellationToken = default) + { + string url = $"http://127.0.0.1:{_port}/exa.language_server_pb.LanguageServerService/{command}"; + return await RequestUrlAsync(url, data, cancellationToken); + } + + public async Task?> GetCompletionsAsync(string absolutePath, string text, Languages.LangInfo language, int cursorPosition, string lineEnding, int tabSize, bool insertSpaces, CancellationToken token) + { + GetCompletionsRequest data = new() + { + metadata = GetMetadata(), + document = new() + { + text = text, + editor_language = language.Name, + language = language.Type, + cursor_offset = (ulong)cursorPosition, + line_ending = lineEnding, + absolute_path = absolutePath, + relative_path = Path.GetFileName(absolutePath) + }, + editor_options = new() + { + tab_size = (ulong)tabSize, + insert_spaces = insertSpaces, + disable_autocomplete_in_comments = !_package.SettingsPage.EnableCommentCompletion, + } + }; + + GetCompletionsResponse? result = await RequestCommandAsync("GetCompletions", data, token); + return result != null ? result.completionItems : []; + } + + public async Task AcceptCompletionAsync(string completionId) + { + AcceptCompletionRequest data = new() + { + metadata = GetMetadata(), + completion_id = completionId + }; + + await RequestCommandAsync("AcceptCompletion", data); + } + + public async Task GetProcessesAsync() + { + return await RequestCommandAsync("GetProcesses", new { }); + } + + public Metadata GetMetadata() + { + return new() + { + request_id = _metadata.request_id++, + api_key = _metadata.api_key, + ide_name = _metadata.ide_name, + ide_version = _metadata.ide_version, + extension_name = _metadata.extension_name, + extension_version = _metadata.extension_version, + session_id = _metadata.session_id, + locale = _metadata.locale, + disable_telemetry = _metadata.disable_telemetry + }; + } } \ No newline at end of file diff --git a/CodeiumVS/LanguageServer/LanguageServerController.cs b/CodeiumVS/LanguageServer/LanguageServerController.cs index 2612c22..a741641 100644 --- a/CodeiumVS/LanguageServer/LanguageServerController.cs +++ b/CodeiumVS/LanguageServer/LanguageServerController.cs @@ -352,7 +352,7 @@ static string RandomString(int length) internal static bool Send(this WebServerRequest request, WebSocket ws) { - if (!ws.IsAlive) + if (ws == null || !ws.IsAlive) { CodeiumVSPackage.Instance.Log("Language Server Controller: Unable to send the request because the connection is closed."); return false; diff --git a/CodeiumVS/NotificationBar.cs b/CodeiumVS/NotificationBar.cs index a3c595c..921d078 100644 --- a/CodeiumVS/NotificationBar.cs +++ b/CodeiumVS/NotificationBar.cs @@ -7,14 +7,17 @@ namespace CodeiumVS; #nullable enable -public class NotificationInfoBar : IVsInfoBarUIEvents +public class NotificationInfoBar : IVsInfoBarUIEvents, IVsShellPropertyEvents { private IVsInfoBarUIElement? view; private uint infoBarEventsCookie; + private uint shellPropertyEventsCookie; + private IVsShell? _vsShell; private IVsInfoBarHost? vsInfoBarHost; + private IVsInfoBarUIFactory? _vsInfoBarFactory; public bool IsShown { get; private set; } @@ -22,6 +25,17 @@ public class NotificationInfoBar : IVsInfoBarUIEvents public IVsInfoBarUIElement? View => view; + public static readonly KeyValuePair[] SupportActions = [ + new KeyValuePair("Ask for support on Discord", delegate + { + CodeiumVSPackage.OpenInBrowser("https://discord.gg/3XFf78nAx5"); + }), + new KeyValuePair("Report issue on GitHub", delegate + { + CodeiumVSPackage.OpenInBrowser("https://github.com/Exafunction/CodeiumVisualStudio/issues/new"); + }), + ]; + public NotificationInfoBar() { } @@ -33,19 +47,27 @@ public void Show(string text, ImageMoniker? icon = null, bool canClose = true, A try { - IVsShell vsShell = ServiceProvider.GlobalProvider.GetService(); - IVsInfoBarUIFactory vsInfoBarFactory = ServiceProvider.GlobalProvider.GetService(); - if (vsShell == null || vsInfoBarFactory == null) return; - - if (vsInfoBarFactory != null && ErrorHandler.Succeeded(vsShell.GetProperty((int)__VSSPROPID7.VSSPROPID_MainWindowInfoBarHost, out var pvar)) && pvar is IVsInfoBarHost vsInfoBarHost) - { - InfoBarModel infoBar = new(text, GetActionsItems(actions), icon ?? KnownMonikers.StatusInformation, canClose); - - view = vsInfoBarFactory.CreateInfoBar(infoBar); - view.Advise(this, out infoBarEventsCookie); + _vsShell = ServiceProvider.GlobalProvider.GetService(); + _vsInfoBarFactory = ServiceProvider.GlobalProvider.GetService(); + if (_vsShell == null || _vsInfoBarFactory == null) return; - this.vsInfoBarHost = vsInfoBarHost; - this.vsInfoBarHost.AddInfoBar(view); + InfoBarModel infoBar = new(text, GetActionsItems(actions), icon ?? KnownMonikers.StatusInformation, canClose); + + view = _vsInfoBarFactory.CreateInfoBar(infoBar); + view.Advise(this, out infoBarEventsCookie); + + if (ErrorHandler.Succeeded(_vsShell.GetProperty((int)__VSSPROPID7.VSSPROPID_MainWindowInfoBarHost, out var pvar))) + { + if (pvar is IVsInfoBarHost vsInfoBarHost) + { + this.vsInfoBarHost = vsInfoBarHost; + this.vsInfoBarHost.AddInfoBar(view); + } + } + else + { + // the MainWindowInfoBarHost has not been created yet, so we delay showing the notification + _vsShell.AdviseShellPropertyChanges(this, out shellPropertyEventsCookie); IsShown = true; OnCloseCallback = onCloseCallback; @@ -108,5 +130,24 @@ void IVsInfoBarUIEvents.OnActionItemClicked(IVsInfoBarUIElement infoBarUIElement ThreadHelper.ThrowIfNotOnUIThread("OnActionItemClicked"); ((Action)actionItem.ActionContext)(); } + + public int OnShellPropertyChange(int propid, object var) + { + ThreadHelper.ThrowIfNotOnUIThread("OnShellPropertyChange"); + + //if (propid == (int)__VSSPROPID7.VSSPROPID_MainWindowInfoBarHost) // for some reaons, this doesn't work + if (_vsShell?.GetProperty((int)__VSSPROPID7.VSSPROPID_MainWindowInfoBarHost, out var pvar) == VSConstants.S_OK) + { + _vsShell?.UnadviseShellPropertyChanges(shellPropertyEventsCookie); + + if (pvar is IVsInfoBarHost vsInfoBarHost) + { + this.vsInfoBarHost = vsInfoBarHost; + this.vsInfoBarHost.AddInfoBar(view); + } + } + + return VSConstants.S_OK; + } } #nullable disable \ No newline at end of file diff --git a/CodeiumVS/Utilities/FileUtilities.cs b/CodeiumVS/Utilities/FileUtilities.cs new file mode 100644 index 0000000..c5890d4 --- /dev/null +++ b/CodeiumVS/Utilities/FileUtilities.cs @@ -0,0 +1,41 @@ +using System.IO; + +namespace CodeiumVS.Utilities; + +internal static class FileUtilities +{ + /// + /// Delete a file in a safe way, if the file is in use, rename it instead + /// + /// + internal static void DeleteSafe(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) + { + if (ex is UnauthorizedAccessException || ex is IOException) + { + // what's more beatiful than nested exceptions... + try + { + string orignalFileName = Path.GetFileName(path); + string randomPath = Path.Combine(Path.GetDirectoryName(path), orignalFileName + "_deleted_" + Path.GetRandomFileName()); + File.Move(path, randomPath); + } + catch (Exception ex2) + { + CodeiumVSPackage.Instance?.Log($"Failed to move the file why trying to delete it, exception: {ex2}"); + VS.MessageBox.ShowError($"Codeium: Failed to move the file why trying to delete it: {path}", "Please see the output windows for more details"); + } + } + else + { + CodeiumVSPackage.Instance?.Log($"Failed to delete file, exception: {ex}"); + VS.MessageBox.ShowError($"Codeium: Failed to delete file: {path}", "Please see the output windows for more details"); + } + } + } +} diff --git a/CodeiumVS/Utilities/ProcessExtensions.cs b/CodeiumVS/Utilities/ProcessExtensions.cs index 1491627..37abbf2 100644 --- a/CodeiumVS/Utilities/ProcessExtensions.cs +++ b/CodeiumVS/Utilities/ProcessExtensions.cs @@ -63,7 +63,7 @@ private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION public UIntPtr PeakJobMemoryUsed; } - public static void MakeProcessExitOnParentExit(Process process) + public static bool MakeProcessExitOnParentExit(Process process) { IntPtr hJob = CreateJobObject(IntPtr.Zero, null); @@ -76,10 +76,11 @@ public static void MakeProcessExitOnParentExit(Process process) if (!SetInformationJobObject(hJob, JobObjectInfoType.ExtendedLimitInformation, extendedInfoPtr, (uint)length)) { - throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + return false; } AssignProcessToJobObject(hJob, process.Handle); + return true; } } diff --git a/CodeiumVS/Windows/ChatToolWindow.cs b/CodeiumVS/Windows/ChatToolWindow.cs index 9dc5081..fa17b7d 100644 --- a/CodeiumVS/Windows/ChatToolWindow.cs +++ b/CodeiumVS/Windows/ChatToolWindow.cs @@ -1,11 +1,9 @@ -using Community.VisualStudio.Toolkit; -using Microsoft.VisualStudio; +using Microsoft.VisualStudio; using Microsoft.VisualStudio.Imaging; using Microsoft.VisualStudio.PlatformUI; using Microsoft.VisualStudio.Shell.Interop; using Microsoft.Web.WebView2.Core; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -349,6 +347,9 @@ private void VSColorTheme_ThemeChanged(ThemeChangedEventArgs e) private void WebView_OnDOMContentLoaded(object sender, CoreWebView2DOMContentLoadedEventArgs e) { if (webView.Source.OriginalString != "about:blank") + { _isChatPageLoaded = true; + _infoBar.Close(); + } } } diff --git a/CodeiumVS/Windows/EnterTokenDialogWindow.cs b/CodeiumVS/Windows/EnterTokenDialogWindow.cs index bc7ddb7..85bcf63 100644 --- a/CodeiumVS/Windows/EnterTokenDialogWindow.cs +++ b/CodeiumVS/Windows/EnterTokenDialogWindow.cs @@ -51,6 +51,6 @@ private void HelpLinkClicked(object sender, System.Windows.Navigation.RequestNav string redirectUrl = "show-auth-token"; string url = $"{portalUrl}/profile?response_type=token&redirect_uri={redirectUrl}&state={state}&scope=openid%20profile%20email&redirect_parameters_type=query"; - CodeiumVSPackage.Instance.OpenInBrowser(url); + CodeiumVSPackage.OpenInBrowser(url); } } \ No newline at end of file