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.
///