diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/UpdatesNotification.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/UpdatesNotification.cs index 7debd1ef1cb..9f00f1b4649 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/UpdatesNotification.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/UpdatesNotification.cs @@ -18,26 +18,61 @@ namespace Microsoft.PowerShell /// /// A Helper class for printing notification on PowerShell startup when there is a new update. /// + /// + /// For the detailed design, please take a look at the corresponding RFC. + /// 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 UpdateCheckEnvVar = "POWERSHELL_UPDATECHECK"; + private const string LTSBuildInfoURL = "https://aka.ms/pwsh-buildinfo-lts"; + private const string StableBuildInfoURL = "https://aka.ms/pwsh-buildinfo-stable"; + private const string PreviewBuildInfoURL = "https://aka.ms/pwsh-buildinfo-preview"; - 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*.*.*_????-??-??"; + /// + /// The version of new update is persisted using a file, not as the file content, but instead baked in the file name in the following template: + /// `update{notification-type}_{version}_{publish-date}` -- held by 's_updateFileNameTemplate', + /// while 's_updateFileNamePattern' holds the pattern of this file name. + /// + private static readonly string s_updateFileNameTemplate, s_updateFileNamePattern; + + /// + /// For each notification type, we need two files to achieve the synchronization for the update check: + /// `_sentinel{notification-type}_` -- held by 's_sentinelFileName'; + /// `sentinel{notification-type}-{year}-{month}-{day}.done` + /// -- held by 's_doneFileNameTemplate', while 's_doneFileNamePattern' holds the pattern of this file name. + /// The {notification-type} part will be the integer value of the corresponding `NotificationType` member. + /// The {year}-{month}-{day} part will be filled with the date of current day when the update check runs. + /// + private static readonly string s_sentinelFileName, s_doneFileNameTemplate, s_doneFileNamePattern; - private static readonly EnumerationOptions s_enumOptions = new EnumerationOptions(); - private static readonly string s_cacheDirectory = Path.Combine(Platform.CacheDirectory, PSVersionInfo.GitCommitId); + private static readonly string s_cacheDirectory; + private static readonly EnumerationOptions s_enumOptions; + private static readonly NotificationType s_notificationType; /// /// Gets a value indicating whether update notification should be done. /// - internal static readonly bool CanNotifyUpdates = !Utils.GetOptOutEnvVariableAsBool(UpdateCheckOptOutEnvVar, defaultValue: false) - && ExperimentalFeature.IsEnabled("PSUpdatesNotification"); + internal static readonly bool CanNotifyUpdates; + + static UpdatesNotification() + { + s_notificationType = GetNotificationType(); + CanNotifyUpdates = s_notificationType != NotificationType.Off && ExperimentalFeature.IsEnabled("PSUpdatesNotification"); + + if (CanNotifyUpdates) + { + s_enumOptions = new EnumerationOptions(); + s_cacheDirectory = Path.Combine(Platform.CacheDirectory, PSVersionInfo.GitCommitId); + + // Build the template/pattern strings for the configured notification type. + string typeNum = ((int)s_notificationType).ToString(); + s_sentinelFileName = $"_sentinel{typeNum}_"; + s_doneFileNameTemplate = $"sentinel{typeNum}-{{0}}-{{1}}-{{2}}.done"; + s_doneFileNamePattern = $"sentinel{typeNum}-*.done"; + s_updateFileNameTemplate = $"update{typeNum}_{{0}}_{{1}}"; + s_updateFileNamePattern = $"update{typeNum}_v*.*.*_????-??-??"; + } + } // 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'. @@ -58,9 +93,11 @@ internal static void ShowUpdateNotification(PSHostUserInterface hostUI) && lastUpdateVersion != null) { string releaseTag = lastUpdateVersion.ToString(); - string notificationMsgTemplate = string.IsNullOrEmpty(lastUpdateVersion.PreReleaseLabel) - ? ManagedEntranceStrings.StableUpdateNotificationMessage - : ManagedEntranceStrings.PreviewUpdateNotificationMessage; + string notificationMsgTemplate = s_notificationType == NotificationType.LTS + ? ManagedEntranceStrings.LTSUpdateNotificationMessage + : string.IsNullOrEmpty(lastUpdateVersion.PreReleaseLabel) + ? ManagedEntranceStrings.StableUpdateNotificationMessage + : ManagedEntranceStrings.PreviewUpdateNotificationMessage; string notificationColor = string.Empty; string resetColor = string.Empty; @@ -141,7 +178,7 @@ internal static async Task CheckForUpdates() // Construct the sentinel file paths for today's check. string todayDoneFileName = string.Format( CultureInfo.InvariantCulture, - DoneFileNameTemplate, + s_doneFileNameTemplate, today.Year.ToString(), today.Month.ToString(), today.Day.ToString()); @@ -156,9 +193,9 @@ internal static async Task CheckForUpdates() try { - // Use 'sentinelFilePath' as the file lock. + // Use 's_sentinelFileName' 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); + string sentinelFilePath = Path.Combine(s_cacheDirectory, s_sentinelFileName); using (new FileStream(sentinelFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, bufferSize: 1, FileOptions.DeleteOnClose)) { if (File.Exists(todayDoneFilePath)) @@ -170,7 +207,7 @@ internal static async Task CheckForUpdates() // 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)) + foreach (string oldFile in Directory.EnumerateFiles(s_cacheDirectory, s_doneFileNamePattern, s_enumOptions)) { File.Delete(oldFile); } @@ -180,8 +217,8 @@ internal static async Task CheckForUpdates() // 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)) + // We try to recover here by cleaning up all update files for the configured notification type. + foreach (string file in Directory.EnumerateFiles(s_cacheDirectory, s_updateFileNamePattern, s_enumOptions)) { File.Delete(file); } @@ -190,8 +227,8 @@ internal static async Task CheckForUpdates() // 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__`. + // 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); @@ -201,7 +238,7 @@ internal static async Task CheckForUpdates() const int dateLength = 10; string newUpdateFileName = string.Format( CultureInfo.InvariantCulture, - UpdateFileNameTemplate, + s_updateFileNameTemplate, release.TagName, release.PublishAt.Substring(0, dateLength)); @@ -254,7 +291,7 @@ private static bool TryParseUpdateFile( lastUpdateVersion = null; lastUpdateDate = default; - var files = Directory.EnumerateFiles(s_cacheDirectory, UpdateFileNamePattern, s_enumOptions); + var files = Directory.EnumerateFiles(s_cacheDirectory, s_updateFileNamePattern, s_enumOptions); var enumerator = files.GetEnumerator(); if (!enumerator.MoveNext()) @@ -272,7 +309,7 @@ private static bool TryParseUpdateFile( return false; } - // OK, only found one update file, which is expected. + // OK, only found one update file for the configured notification type, which is expected. // Now let's parse the file name. string updateFileName = Path.GetFileName(updateFilePath); int dateStartIndex = updateFileName.LastIndexOf('_') + 1; @@ -304,7 +341,14 @@ private static bool TryParseUpdateFile( private static async Task QueryNewReleaseAsync(SemanticVersion baselineVersion) { bool isStableRelease = string.IsNullOrEmpty(PSVersionInfo.PSCurrentVersion.PreReleaseLabel); - string queryUri = isStableRelease ? LatestReleaseUri : Last3ReleasesUri; + string[] queryUris = s_notificationType switch + { + NotificationType.LTS => new[] { LTSBuildInfoURL }, + NotificationType.Default => isStableRelease + ? new[] { StableBuildInfoURL } + : new[] { StableBuildInfoURL, PreviewBuildInfoURL }, + _ => Array.Empty() + }; using var client = new HttpClient(); @@ -312,52 +356,76 @@ private static async Task QueryNewReleaseAsync(SemanticVersion baseline 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; + SemanticVersion highestVersion = baselineVersion; var settings = new JsonSerializerSettings() { DateParseHandling = DateParseHandling.None }; var serializer = JsonSerializer.Create(settings); - if (isStableRelease) + foreach (string queryUri in queryUris) { + // 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); + JObject release = serializer.Deserialize(jsonReader); - var tagName = release["tag_name"].ToString(); + var tagName = release["ReleaseTag"].ToString(); var version = SemanticVersion.Parse(tagName.Substring(1)); - if (version > baselineVersion) + if (version > highestVersion) { - var publishAt = release["published_at"].ToString(); + highestVersion = version; + var publishAt = release["ReleaseDate"].ToString(); releaseToReturn = new Release(publishAt, tagName); } - - return releaseToReturn; } - // The current 'pwsh' is a preview release. - JArray last3Releases = serializer.Deserialize(jsonReader); - SemanticVersion highestVersion = baselineVersion; + return releaseToReturn; + } - for (int i = 0; i < last3Releases.Count; i++) + /// + /// Get the notification type setting. + /// + private static NotificationType GetNotificationType() + { + string str = Environment.GetEnvironmentVariable(UpdateCheckEnvVar); + if (string.IsNullOrEmpty(str)) { - JToken release = last3Releases[i]; - var tagName = release["tag_name"].ToString(); - var version = SemanticVersion.Parse(tagName.Substring(1)); + return NotificationType.Default; + } - if (version > highestVersion) - { - highestVersion = version; - var publishAt = release["published_at"].ToString(); - releaseToReturn = new Release(publishAt, tagName); - } + if (Enum.TryParse(str, ignoreCase: true, out NotificationType type)) + { + return type; } - return releaseToReturn; + return NotificationType.Default; + } + + /// + /// Notification type that can be configured. + /// + private enum NotificationType + { + /// + /// Turn off the udpate notification. + /// + Off = 0, + + /// + /// Give you the default behaviors: + /// - the preview version 'pwsh' checks for the new preview version and the new GA version. + /// - the GA version 'pwsh' checks for the new GA version only. + /// + Default = 1, + + /// + /// Both preview and GA version 'pwsh' checks for the new LTS version only. + /// + LTS = 2 } private class Release diff --git a/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx b/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx index 5a64d207a8d..d174a6bfd5e 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx +++ b/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx @@ -137,6 +137,12 @@ Type 'help' to get help. {1} A new PowerShell stable release is available: v{0} {2} {1} Upgrade now, or check out the release page at:{3}{2} {1} https://aka.ms/PowerShell-Release?tag=v{0} {4}{2} + + + + {1} A new PowerShell LTS release is available: v{0} {2} + {1} Upgrade now, or check out the release page at:{3}{2} + {1} https://aka.ms/PowerShell-Release?tag=v{0} {4}{2} diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index fd92024a64e..8afcf5c93cc 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -303,36 +303,6 @@ 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 bee35bca282..401b57f54c2 100644 --- a/src/System.Management.Automation/utils/Telemetry.cs +++ b/src/System.Management.Automation/utils/Telemetry.cs @@ -105,7 +105,7 @@ public static class ApplicationInsightsTelemetry static ApplicationInsightsTelemetry() { // If we can't send telemetry, there's no reason to do any of this - CanSendTelemetry = !Utils.GetOptOutEnvVariableAsBool(name: _telemetryOptoutEnvVar, defaultValue: false); + CanSendTelemetry = !GetEnvironmentVariableAsBool(name: _telemetryOptoutEnvVar, defaultValue: false); if (CanSendTelemetry) { TelemetryConfiguration configuration = TelemetryConfiguration.CreateDefault(); @@ -137,6 +137,35 @@ 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. ///