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