diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs index 6f44ae14a01..02cb00c52ce 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs @@ -1018,6 +1018,11 @@ private void ParseHelper(string[] args) if (_showBanner && !_showHelp) { DisplayBanner(); + + if (UpdatesNotification.CanNotifyUpdates) + { + UpdatesNotification.ShowUpdateNotification(_hostUI); + } } Dbg.Assert( diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs index bc6467f815d..393b1bd3648 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs @@ -61,8 +61,6 @@ internal sealed partial class ConsoleHost [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SystemParametersInfo(uint uiAction, uint uiParam, ref bool pvParam, uint fWinIni); - // NTRAID#Windows Out Of Band Releases-915506-2005/09/09 - // Removed HandleUnexpectedExceptions infrastructure /// /// Internal Entry point in msh console host implementation. /// @@ -78,8 +76,8 @@ internal sealed partial class ConsoleHost /// /// The exit code for the shell. /// - /// NTRAID#Windows OS Bugs-1036968-2005/01/20-sburns The behavior here is related to monitor work. The low word of the - /// exit code is available for the user. The high word is reserved for the shell and monitor. + /// The behavior here is related to monitor work. + /// The low word of the exit code is available for the user. The high word is reserved for the shell and monitor. /// /// The shell process needs to return: /// @@ -98,13 +96,10 @@ internal sealed partial class ConsoleHost /// or 0xFFFE0000 (user hit ctrl-break), the monitor should restart the shell.exe. Otherwise, the monitor should exit /// with the same exit code as the shell.exe. /// - /// Anyone checking the exit code of the shell or monitor can mask off the hiword to determine the exit code passed + /// Anyone checking the exit code of the shell or monitor can mask off the high word to determine the exit code passed /// by the script that the shell last executed. /// - internal static int Start( - string bannerText, - string helpText, - string[] args) + internal static int Start(string bannerText, string helpText, string[] args) { #if DEBUG if (Environment.GetEnvironmentVariable("POWERSHELL_DEBUG_STARTUP") != null) @@ -162,10 +157,8 @@ internal static int Start( hostException = e; } - s_cpp = new CommandLineParameterParser( - (s_theConsoleHost != null) ? s_theConsoleHost.UI : new NullHostUserInterface(), - bannerText, helpText); - + PSHostUserInterface hostUi = s_theConsoleHost?.UI ?? new NullHostUserInterface(); + s_cpp = new CommandLineParameterParser(hostUi, bannerText, helpText); s_cpp.Parse(args); #if UNIX @@ -239,10 +232,20 @@ internal static int Start( throw hostException; } - ProfileOptimization.StartProfile( - s_theConsoleHost.LoadPSReadline() - ? "StartupProfileData-Interactive" - : "StartupProfileData-NonInteractive"); + if (s_theConsoleHost.LoadPSReadline()) + { + ProfileOptimization.StartProfile("StartupProfileData-Interactive"); + + if (UpdatesNotification.CanNotifyUpdates) + { + // Start a task in the background to check for the update release. + _ = UpdatesNotification.CheckForUpdates(); + } + } + else + { + ProfileOptimization.StartProfile("StartupProfileData-NonInteractive"); + } s_theConsoleHost.BindBreakHandler(); PSHost.IsStdOutputRedirected = Console.IsOutputRedirected; @@ -1532,7 +1535,7 @@ private bool LoadPSReadline() // Don't load PSReadline if: // * we don't think the process will be interactive, e.g. -command or -file // - exception: when -noexit is specified, we will be interactive after the command/file finishes - // * -noninteractive: this should be obvious, they've asked that we don't every prompt + // * -noninteractive: this should be obvious, they've asked that we don't ever prompt // // Note that PSReadline doesn't support redirected stdin/stdout, but we don't check that here because // a future version might, and we should automatically load it at that unknown point in the future. diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/UpdatesNotification.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/UpdatesNotification.cs new file mode 100644 index 00000000000..c47b0506e92 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/UpdatesNotification.cs @@ -0,0 +1,349 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using System.IO; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.NetworkInformation; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.PowerShell +{ + /// + /// A Helper class for printing notification on PowerShell startup when there is a new update. + /// + internal static class UpdatesNotification + { + private const string UpdateCheckOptOutEnvVar = "POWERSHELL_UPDATECHECK_OPTOUT"; + private const string Last3ReleasesUri = "https://api.github.com/repos/PowerShell/PowerShell/releases?per_page=3"; + private const string LatestReleaseUri = "https://api.github.com/repos/PowerShell/PowerShell/releases/latest"; + + private const string SentinelFileName = "_sentinel_"; + private const string DoneFileNameTemplate = "sentinel-{0}-{1}-{2}.done"; + private const string DoneFileNamePattern = "sentinel-*.done"; + private const string UpdateFileNameTemplate = "update_{0}_{1}"; + private const string UpdateFileNamePattern = "update_v*.*.*_????-??-??"; + + private static readonly EnumerationOptions s_enumOptions = new EnumerationOptions(); + private static readonly string s_cacheDirectory = Path.Combine(Platform.CacheDirectory, PSVersionInfo.GitCommitId); + + /// + /// Gets a value indicating whether update notification should be done. + /// + internal static readonly bool CanNotifyUpdates = !Utils.GetOptOutEnvVariableAsBool(UpdateCheckOptOutEnvVar, defaultValue: false) + && ExperimentalFeature.IsEnabled("PSUpdatesNotification"); + + // Maybe we shouldn't do update check and show notification when it's from a mini-shell, meaning when + // 'ConsoleShell.Start' is not called by 'ManagedEntrance.Start'. + // But it seems so unusual that it's probably not worth bothering. Also, a mini-shell probably should + // just disable the update notification feature by setting the opt-out environment variable. + + internal static void ShowUpdateNotification(PSHostUserInterface hostUI) + { + if (!Directory.Exists(s_cacheDirectory)) + { + return; + } + + if (TryParseUpdateFile( + updateFilePath: out _, + out SemanticVersion lastUpdateVersion, + lastUpdateDate: out _) + && lastUpdateVersion != null) + { + string releaseTag = lastUpdateVersion.ToString(); + string notificationMsgTemplate = string.IsNullOrEmpty(lastUpdateVersion.PreReleaseLabel) + ? ManagedEntranceStrings.StableUpdateNotificationMessage + : ManagedEntranceStrings.PreviewUpdateNotificationMessage; + + string notificationMsg = string.Format(CultureInfo.CurrentCulture, notificationMsgTemplate, releaseTag); + hostUI.WriteLine(notificationMsg); + } + } + + internal static async Task CheckForUpdates() + { + // Delay the update check for 3 seconds so that it has the minimal impact on startup. + await Task.Delay(3000); + + // A self-built pwsh for development purpose has the SHA1 commit hash baked in 'GitCommitId', + // which is 40 characters long. So we can quickly check the length of 'GitCommitId' to tell + // if this is a self-built pwsh, and skip the update check if so. + if (PSVersionInfo.GitCommitId.Length > 40) + { + return; + } + + // If the host is not connect to a network, skip the rest of the check. + if (!NetworkInterface.GetIsNetworkAvailable()) + { + return; + } + + // Create the update cache directory if it doesn't exists + if (!Directory.Exists(s_cacheDirectory)) + { + Directory.CreateDirectory(s_cacheDirectory); + } + + bool parseSuccess = TryParseUpdateFile( + out string updateFilePath, + out SemanticVersion lastUpdateVersion, + out DateTime lastUpdateDate); + + DateTime today = DateTime.UtcNow; + if (parseSuccess && updateFilePath != null && (today - lastUpdateDate).TotalDays < 7) + { + // There is an existing update file, and the last update was less than 1 week ago. + // It's unlikely a new version is released within 1 week, so we can skip this check. + return; + } + + // Construct the sentinel file paths for today's check. + string todayDoneFileName = string.Format( + CultureInfo.InvariantCulture, + DoneFileNameTemplate, + today.Year.ToString(), + today.Month.ToString(), + today.Day.ToString()); + + string todayDoneFilePath = Path.Combine(s_cacheDirectory, todayDoneFileName); + if (File.Exists(todayDoneFilePath)) + { + // A successful update check has been done today. + // We can skip this update check. + return; + } + + try + { + // Use 'sentinelFilePath' as the file lock. + // The update-check tasks started by every 'pwsh' process of the same version will compete on holding this file. + string sentinelFilePath = Path.Combine(s_cacheDirectory, SentinelFileName); + using (new FileStream(sentinelFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, bufferSize: 1, FileOptions.DeleteOnClose)) + { + if (File.Exists(todayDoneFilePath)) + { + // After acquiring the file lock, it turns out a successful check has already been done for today. + // Then let's skip this update check. + return; + } + + // Now it's guaranteed that this is the only process that reaches here. + // Clean up the old '.done' file, there should be only one of it. + foreach (string oldFile in Directory.EnumerateFiles(s_cacheDirectory, DoneFileNamePattern, s_enumOptions)) + { + File.Delete(oldFile); + } + + if (!parseSuccess) + { + // The update file is corrupted, either because more than one update files were found unexpectedly, + // or because the update file name failed to be parsed into a release version and a publish date. + // This is **very unlikely** to happen unless the file is accidentally altered manually. + // We try to recover here by cleaning up all update files. + foreach (string file in Directory.EnumerateFiles(s_cacheDirectory, UpdateFileNamePattern, s_enumOptions)) + { + File.Delete(file); + } + } + + // Do the real update check: + // - Send HTTP request to query for the new release/pre-release; + // - If there is a valid new release that should be reported to the user, + // create the file `update__` when no `update` file exists, + // or rename the existing file to `update__`. + SemanticVersion baselineVersion = lastUpdateVersion ?? PSVersionInfo.PSCurrentVersion; + Release release = await QueryNewReleaseAsync(baselineVersion); + + if (release != null) + { + // The date part of the string is 'YYYY-MM-DD'. + const int dateLength = 10; + string newUpdateFileName = string.Format( + CultureInfo.InvariantCulture, + UpdateFileNameTemplate, + release.TagName, + release.PublishAt.Substring(0, dateLength)); + + string newUpdateFilePath = Path.Combine(s_cacheDirectory, newUpdateFileName); + + if (updateFilePath == null) + { + new FileStream(newUpdateFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None).Close(); + } + else + { + File.Move(updateFilePath, newUpdateFilePath); + } + } + + // Finally, create the `todayDoneFilePath` file as an indicator that a successful update check has finished today. + new FileStream(todayDoneFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None).Close(); + } + } + catch (Exception) + { + // There are 2 possible reason for the exception: + // 1. An update check initiated from another `pwsh` process is in progress. + // It's OK to just return and let that update check to finish the work. + // 2. The update check failed (ex. internet connectivity issue, GitHub service failure). + // It's OK to just return and let another `pwsh` do the check at later time. + } + } + + /// + /// Check for the existence of the update file and parse the file name if it exists. + /// + /// Get the exact update file path. + /// Get the version of the new release. + /// Get the publish date of the new release. + /// + /// False, when + /// 1. found more than one update files that matched the pattern; OR + /// 2. found only one update file, but failed to parse its name for version and publish date. + /// True, when + /// 1. no update file was found, namely no new updates yet; + /// 2. found only one update file, and succeeded to parse its name for version and publish date. + /// + private static bool TryParseUpdateFile( + out string updateFilePath, + out SemanticVersion lastUpdateVersion, + out DateTime lastUpdateDate) + { + updateFilePath = null; + lastUpdateVersion = null; + lastUpdateDate = default; + + var files = Directory.EnumerateFiles(s_cacheDirectory, UpdateFileNamePattern, s_enumOptions); + var enumerator = files.GetEnumerator(); + + if (!enumerator.MoveNext()) + { + // It's OK that an update file doesn't exist. This could happen when there is no new updates yet. + return true; + } + + updateFilePath = enumerator.Current; + if (enumerator.MoveNext()) + { + // More than 1 files were found that match the pattern. This is a corrupted state. + // Theoretically, there should be only one update file at any point of time. + updateFilePath = null; + return false; + } + + // OK, only found one update file, which is expected. + // Now let's parse the file name. + string updateFileName = Path.GetFileName(updateFilePath); + int dateStartIndex = updateFileName.LastIndexOf('_') + 1; + + if (!DateTime.TryParse( + updateFileName.AsSpan().Slice(dateStartIndex), + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out lastUpdateDate)) + { + updateFilePath = null; + return false; + } + + int versionStartIndex = updateFileName.IndexOf('_') + 2; + int versionLength = dateStartIndex - versionStartIndex - 1; + string versionString = updateFileName.Substring(versionStartIndex, versionLength); + + if (SemanticVersion.TryParse(versionString, out lastUpdateVersion)) + { + return true; + } + + updateFilePath = null; + lastUpdateDate = default; + return false; + } + + private static async Task QueryNewReleaseAsync(SemanticVersion baselineVersion) + { + bool isStableRelease = string.IsNullOrEmpty(PSVersionInfo.PSCurrentVersion.PreReleaseLabel); + string queryUri = isStableRelease ? LatestReleaseUri : Last3ReleasesUri; + + using var client = new HttpClient(); + + string userAgent = string.Format(CultureInfo.InvariantCulture, "PowerShell {0}", PSVersionInfo.GitCommitId); + client.DefaultRequestHeaders.Add("User-Agent", userAgent); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Query the GitHub Rest API and throw if the query fails. + HttpResponseMessage response = await client.GetAsync(queryUri); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(stream); + using var jsonReader = new JsonTextReader(reader); + + Release releaseToReturn = null; + var settings = new JsonSerializerSettings() { DateParseHandling = DateParseHandling.None }; + var serializer = JsonSerializer.Create(settings); + + if (isStableRelease) + { + JObject release = serializer.Deserialize(jsonReader); + var tagName = release["tag_name"].ToString(); + var version = SemanticVersion.Parse(tagName.Substring(1)); + + if (version > baselineVersion) + { + var publishAt = release["published_at"].ToString(); + releaseToReturn = new Release(publishAt, tagName); + } + + return releaseToReturn; + } + + // The current 'pwsh' is a preview release. + JArray last3Releases = serializer.Deserialize(jsonReader); + SemanticVersion highestVersion = baselineVersion; + + for (int i = 0; i < last3Releases.Count; i++) + { + JToken release = last3Releases[i]; + var tagName = release["tag_name"].ToString(); + var version = SemanticVersion.Parse(tagName.Substring(1)); + + if (version > highestVersion) + { + highestVersion = version; + var publishAt = release["published_at"].ToString(); + releaseToReturn = new Release(publishAt, tagName); + } + } + + return releaseToReturn; + } + + private class Release + { + internal Release(string publishAt, string tagName) + { + PublishAt = publishAt; + TagName = tagName; + } + + /// + /// The datetime stamp is in UTC. For example: 2019-03-28T18:42:02Z. + /// + internal string PublishAt { get; } + + /// + /// The release tag name. + /// + internal string TagName { get; } + } + } +} diff --git a/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx b/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx index c82668c8eae..5d5feaaf761 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx +++ b/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx @@ -127,6 +127,18 @@ Type 'help' to get help. Warning: PowerShell detected that you might be using a screen reader and has disabled PSReadLine for compatibility purposes. If you want to re-enable it, run 'Import-Module PSReadLine'. + + !! A new PowerShell preview release is available: v{0} !! +Upgrade now, or check out the release page at: +https://github.com/PowerShell/PowerShell/releases/tag/v{0} + + + + !! A new PowerShell stable release is available: v{0} !! +Upgrade now, or check out the release page at: +https://github.com/PowerShell/PowerShell/releases/tag/v{0} + + Usage: pwsh[.exe] [-Login] [[-File] <filePath> [args]] [-Command { - | <script-block> [-args <arg-array>] diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 24994bc3e3d..e3db988e4b4 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -117,7 +117,10 @@ static ExperimentalFeature() description: "Support the ternary operator in PowerShell language"), new ExperimentalFeature( name: "PSErrorView", - description: "New formatting for ErrorRecord") + description: "New formatting for ErrorRecord"), + new ExperimentalFeature( + name: "PSUpdatesNotification", + description: "Print notification message when new releases are available") }; EngineExperimentalFeatures = new ReadOnlyCollection(engineFeatures); diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index af9fce89e9f..c9ef6cb663d 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -303,6 +303,36 @@ internal static int CombineHashCodes(int h1, int h2, int h3, int h4, int h5, int /// internal static string[] AllowedEditionValues = { "Desktop", "Core" }; + /// + /// Utility method to interpret the value of an opt-out environment variable. + /// e.g. POWERSHELL_TELEMETRY_OPTOUT and POWERSHELL_UPDATECHECK_OPTOUT. + /// + /// The name of the environment variable. + /// If the environment variable is not set, use this as the default value. + /// A boolean representing the value of the environment variable. + internal static bool GetOptOutEnvVariableAsBool(string name, bool defaultValue) + { + string str = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrEmpty(str)) + { + return defaultValue; + } + + switch (str.ToLowerInvariant()) + { + case "true": + case "1": + case "yes": + return true; + case "false": + case "0": + case "no": + return false; + default: + return defaultValue; + } + } + /// /// Helper fn to check byte[] arg for null. /// diff --git a/src/System.Management.Automation/utils/Telemetry.cs b/src/System.Management.Automation/utils/Telemetry.cs index aceb8fd5e28..ecd1a126aee 100644 --- a/src/System.Management.Automation/utils/Telemetry.cs +++ b/src/System.Management.Automation/utils/Telemetry.cs @@ -99,7 +99,7 @@ public static class ApplicationInsightsTelemetry static ApplicationInsightsTelemetry() { // If we can't send telemetry, there's no reason to do any of this - CanSendTelemetry = !GetEnvironmentVariableAsBool(name: _telemetryOptoutEnvVar, defaultValue: false); + CanSendTelemetry = !Utils.GetOptOutEnvVariableAsBool(name: _telemetryOptoutEnvVar, defaultValue: false); if (CanSendTelemetry) { s_telemetryClient = new TelemetryClient(); @@ -129,35 +129,6 @@ static ApplicationInsightsTelemetry() } } - /// - /// Determine whether the environment variable is set and how. - /// - /// The name of the environment variable. - /// If the environment variable is not set, use this as the default value. - /// A boolean representing the value of the environment variable. - private static bool GetEnvironmentVariableAsBool(string name, bool defaultValue) - { - var str = Environment.GetEnvironmentVariable(name); - if (string.IsNullOrEmpty(str)) - { - return defaultValue; - } - - switch (str.ToLowerInvariant()) - { - case "true": - case "1": - case "yes": - return true; - case "false": - case "0": - case "no": - return false; - default: - return defaultValue; - } - } - /// /// Send telemetry as a metric. ///